Merge pull request #1001 from zed-industries/request-to-join-project

Antonio Scandurra created

Request to join projects instead of sharing/unsharing

Change summary

Cargo.lock                                             |    2 
assets/themes/cave-dark.json                           |   46 
assets/themes/cave-light.json                          |   46 
assets/themes/solarized-dark.json                      |   46 
assets/themes/solarized-light.json                     |   46 
assets/themes/sulphurpool-dark.json                    |   46 
assets/themes/sulphurpool-light.json                   |   46 
crates/client/Cargo.toml                               |    4 
crates/client/src/client.rs                            |    6 
crates/client/src/user.rs                              |   36 
crates/collab/src/rpc.rs                               |  739 ++++--
crates/collab/src/rpc/store.rs                         |  416 +--
crates/contacts_panel/Cargo.toml                       |    1 
crates/contacts_panel/src/contact_notification.rs      |  179 -
crates/contacts_panel/src/contacts_panel.rs            |   73 
crates/contacts_panel/src/join_project_notification.rs |   80 
crates/contacts_panel/src/notifications.rs             |  113 +
crates/gpui/src/app.rs                                 |   51 
crates/gpui/src/presenter.rs                           |    8 
crates/project/Cargo.toml                              |    1 
crates/project/src/project.rs                          |  294 +
crates/project/src/worktree.rs                         |   57 
crates/rpc/proto/zed.proto                             |  214 +
crates/rpc/src/proto.rs                                |   12 
crates/rpc/src/rpc.rs                                  |    2 
crates/theme/src/theme.rs                              |    5 
crates/workspace/src/waiting_room.rs                   |  190 +
crates/workspace/src/workspace.rs                      |   95 
crates/zed/src/zed.rs                                  |   23 
script/watch-themes                                    |    7 
styles/dist/dark.json                                  |  531 -----
styles/dist/light.json                                 |  531 -----
styles/dist/solarized-dark.json                        |   52 
styles/dist/solarized-light.json                       |   64 
styles/dist/tokens.json                                | 1270 ++++++++++-
styles/src/buildThemes.ts                              |   10 
styles/src/buildTokens.ts                              |   10 
styles/src/styleTree/app.ts                            |    2 
styles/src/styleTree/chatPanel.ts                      |    2 
styles/src/styleTree/commandPalette.ts                 |    2 
styles/src/styleTree/components.ts                     |    2 
styles/src/styleTree/contactFinder.ts                  |    2 
styles/src/styleTree/contactNotification.ts            |    2 
styles/src/styleTree/contactsPanel.ts                  |   18 
styles/src/styleTree/editor.ts                         |    2 
styles/src/styleTree/picker.ts                         |    2 
styles/src/styleTree/projectDiagnostics.ts             |    2 
styles/src/styleTree/projectPanel.ts                   |    2 
styles/src/styleTree/search.ts                         |    2 
styles/src/styleTree/statusBar.ts                      |    2 
styles/src/styleTree/workspace.ts                      |   10 
styles/src/themes.ts                                   |   16 
styles/src/themes/cave.ts                              |    2 
styles/src/themes/common/base16.ts                     |    4 
styles/src/themes/common/theme.ts                      |    4 
styles/src/themes/gruvbox.ts                           |   30 
styles/src/themes/solarized.ts                         |    2 
styles/src/themes/sulphurpool.ts                       |    2 
58 files changed, 2,962 insertions(+), 2,502 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -768,6 +768,7 @@ dependencies = [
  "anyhow",
  "async-recursion",
  "async-tungstenite",
+ "collections",
  "futures",
  "gpui",
  "image",
@@ -3404,6 +3405,7 @@ dependencies = [
  "sum_tree",
  "tempdir",
  "text",
+ "thiserror",
  "toml",
  "unindent",
  "util",

assets/themes/cave-dark.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#26232a",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#e2dfe7",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#8b8792",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#26232a",
-      "hover": {
-        "background": "#332f38"
-      },
-      "active": {
-        "background": "#3f3b45"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/cave-light.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#e2dfe7",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#26232a",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#585260",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#e2dfe7",
-      "hover": {
-        "background": "#ccc9d2"
-      },
-      "active": {
-        "background": "#b7b3bd"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/solarized-dark.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#073642",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#eee8d5",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#93a1a1",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#073642",
-      "hover": {
-        "background": "#1b444f"
-      },
-      "active": {
-        "background": "#30525c"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/solarized-light.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#eee8d5",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#073642",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#586e75",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#eee8d5",
-      "hover": {
-        "background": "#d7d6c8"
-      },
-      "active": {
-        "background": "#c1c5bb"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/sulphurpool-dark.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#293256",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#dfe2f1",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#979db4",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#293256",
-      "hover": {
-        "background": "#363f62"
-      },
-      "active": {
-        "background": "#444c6f"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

assets/themes/sulphurpool-light.json 🔗

@@ -90,6 +90,16 @@
   },
   "workspace": {
     "background": "#dfe2f1",
+    "joining_project_avatar": {
+      "corner_radius": 40,
+      "width": 80
+    },
+    "joining_project_message": {
+      "padding": 12,
+      "family": "Zed Sans",
+      "color": "#293256",
+      "size": 18
+    },
     "leader_border_opacity": 0.7,
     "leader_border_width": 2,
     "tab": {
@@ -1359,41 +1369,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "shared_project_row": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#5e6687",
-        "size": 14,
-        "margin": {
-          "left": 8,
-          "right": 6
-        }
-      },
-      "guests": {
-        "margin": {
-          "left": 8,
-          "right": 8
-        }
-      },
-      "padding": {
-        "left": 12,
-        "right": 12
-      },
-      "background": "#dfe2f1",
-      "hover": {
-        "background": "#cdd1e2"
-      },
-      "active": {
-        "background": "#bbc0d3"
-      }
-    },
-    "unshared_project_row": {
+    "project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {

crates/client/Cargo.toml 🔗

@@ -8,9 +8,10 @@ path = "src/client.rs"
 doctest = false
 
 [features]
-test-support = ["gpui/test-support", "rpc/test-support"]
+test-support = ["collections/test-support", "gpui/test-support", "rpc/test-support"]
 
 [dependencies]
+collections = { path = "../collections" }
 gpui = { path = "../gpui" }
 util = { path = "../util" }
 rpc = { path = "../rpc" }
@@ -33,5 +34,6 @@ tiny_http = "0.8"
 url = "2.2"
 
 [dev-dependencies]
+collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }

crates/client/src/client.rs 🔗

@@ -1106,7 +1106,7 @@ mod tests {
         let (done_tx1, mut done_rx1) = smol::channel::unbounded();
         let (done_tx2, mut done_rx2) = smol::channel::unbounded();
         client.add_model_message_handler(
-            move |model: ModelHandle<Model>, _: TypedEnvelope<proto::UnshareProject>, _, cx| {
+            move |model: ModelHandle<Model>, _: TypedEnvelope<proto::JoinProject>, _, cx| {
                 match model.read_with(&cx, |model, _| model.id) {
                     1 => done_tx1.try_send(()).unwrap(),
                     2 => done_tx2.try_send(()).unwrap(),
@@ -1135,8 +1135,8 @@ mod tests {
         let subscription3 = model3.update(cx, |_, cx| client.add_model_for_remote_entity(3, cx));
         drop(subscription3);
 
-        server.send(proto::UnshareProject { project_id: 1 });
-        server.send(proto::UnshareProject { project_id: 2 });
+        server.send(proto::JoinProject { project_id: 1 });
+        server.send(proto::JoinProject { project_id: 2 });
         done_rx1.next().await.unwrap();
         done_rx2.next().await.unwrap();
     }

crates/client/src/user.rs 🔗

@@ -1,13 +1,11 @@
 use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
 use anyhow::{anyhow, Context, Result};
+use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
 use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{prelude::Stream, sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
-use std::{
-    collections::{hash_map::Entry, HashMap, HashSet},
-    sync::{Arc, Weak},
-};
+use std::sync::{Arc, Weak};
 use util::TryFutureExt as _;
 
 #[derive(Debug)]
@@ -17,6 +15,26 @@ pub struct User {
     pub avatar: Option<Arc<ImageData>>,
 }
 
+impl PartialOrd for User {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(&other))
+    }
+}
+
+impl Ord for User {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.github_login.cmp(&other.github_login)
+    }
+}
+
+impl PartialEq for User {
+    fn eq(&self, other: &Self) -> bool {
+        self.id == other.id && self.github_login == other.github_login
+    }
+}
+
+impl Eq for User {}
+
 #[derive(Debug)]
 pub struct Contact {
     pub user: Arc<User>,
@@ -27,9 +45,8 @@ pub struct Contact {
 #[derive(Debug)]
 pub struct ProjectMetadata {
     pub id: u64,
-    pub is_shared: bool,
     pub worktree_root_names: Vec<String>,
-    pub guests: Vec<Arc<User>>,
+    pub guests: BTreeSet<Arc<User>>,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -170,7 +187,7 @@ impl UserStore {
                     self.client.upgrade().unwrap().id,
                     message
                 );
-                let mut user_ids = HashSet::new();
+                let mut user_ids = HashSet::default();
                 for contact in &message.contacts {
                     user_ids.insert(contact.user_id);
                     user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
@@ -547,9 +564,9 @@ impl Contact {
             .await?;
         let mut projects = Vec::new();
         for project in contact.projects {
-            let mut guests = Vec::new();
+            let mut guests = BTreeSet::new();
             for participant_id in project.guests {
-                guests.push(
+                guests.insert(
                     user_store
                         .update(cx, |user_store, cx| {
                             user_store.fetch_user(participant_id, cx)
@@ -560,7 +577,6 @@ impl Contact {
             projects.push(ProjectMetadata {
                 id: project.id,
                 worktree_root_names: project.worktree_root_names.clone(),
-                is_shared: project.is_shared,
                 guests,
             });
         }

crates/collab/src/rpc.rs 🔗

@@ -66,6 +66,11 @@ impl<R: RequestMessage> Response<R> {
         self.server.peer.respond(self.receipt, payload)?;
         Ok(())
     }
+
+    fn into_receipt(self) -> Receipt<R> {
+        self.responded.store(true, SeqCst);
+        self.receipt
+    }
 }
 
 pub struct Server {
@@ -115,10 +120,9 @@ impl Server {
             .add_request_handler(Server::ping)
             .add_request_handler(Server::register_project)
             .add_message_handler(Server::unregister_project)
-            .add_request_handler(Server::share_project)
-            .add_message_handler(Server::unshare_project)
             .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
+            .add_message_handler(Server::respond_to_join_project_request)
             .add_request_handler(Server::register_worktree)
             .add_message_handler(Server::unregister_worktree)
             .add_request_handler(Server::update_worktree)
@@ -337,31 +341,59 @@ impl Server {
     #[instrument(skip(self), err)]
     async fn sign_out(self: &mut Arc<Self>, connection_id: ConnectionId) -> Result<()> {
         self.peer.disconnect(connection_id);
-        let removed_connection = self.store_mut().await.remove_connection(connection_id)?;
 
-        for (project_id, project) in removed_connection.hosted_projects {
-            if let Some(share) = project.share {
-                broadcast(connection_id, share.guests.keys().copied(), |conn_id| {
+        let removed_user_id = {
+            let mut store = self.store_mut().await;
+            let removed_connection = store.remove_connection(connection_id)?;
+
+            for (project_id, project) in removed_connection.hosted_projects {
+                broadcast(connection_id, project.guests.keys().copied(), |conn_id| {
                     self.peer
-                        .send(conn_id, proto::UnshareProject { project_id })
+                        .send(conn_id, proto::UnregisterProject { project_id })
                 });
+
+                for (_, receipts) in project.join_requests {
+                    for receipt in receipts {
+                        self.peer.respond(
+                            receipt,
+                            proto::JoinProjectResponse {
+                                variant: Some(proto::join_project_response::Variant::Decline(
+                                    proto::join_project_response::Decline {
+                                        reason: proto::join_project_response::decline::Reason::WentOffline as i32
+                                    },
+                                )),
+                            },
+                        )?;
+                    }
+                }
             }
-        }
 
-        for (project_id, peer_ids) in removed_connection.guest_project_ids {
-            broadcast(connection_id, peer_ids, |conn_id| {
-                self.peer.send(
-                    conn_id,
-                    proto::RemoveProjectCollaborator {
-                        project_id,
-                        peer_id: connection_id.0,
-                    },
-                )
-            });
-        }
+            for project_id in removed_connection.guest_project_ids {
+                if let Some(project) = store.project(project_id).trace_err() {
+                    broadcast(connection_id, project.connection_ids(), |conn_id| {
+                        self.peer.send(
+                            conn_id,
+                            proto::RemoveProjectCollaborator {
+                                project_id,
+                                peer_id: connection_id.0,
+                            },
+                        )
+                    });
+                    if project.guests.is_empty() {
+                        self.peer
+                            .send(
+                                project.host_connection_id,
+                                proto::ProjectUnshared { project_id },
+                            )
+                            .trace_err();
+                    }
+                }
+            }
 
-        self.update_user_contacts(removed_connection.user_id)
-            .await?;
+            removed_connection.user_id
+        };
+
+        self.update_user_contacts(removed_user_id).await?;
 
         Ok(())
     }
@@ -396,31 +428,32 @@ impl Server {
         self: Arc<Server>,
         request: TypedEnvelope<proto::UnregisterProject>,
     ) -> Result<()> {
-        let user_id = {
+        let (user_id, project) = {
             let mut state = self.store_mut().await;
-            state.unregister_project(request.payload.project_id, request.sender_id)?;
-            state.user_id_for_connection(request.sender_id)?
+            let project =
+                state.unregister_project(request.payload.project_id, request.sender_id)?;
+            (state.user_id_for_connection(request.sender_id)?, project)
         };
+        for (_, receipts) in project.join_requests {
+            for receipt in receipts {
+                self.peer.respond(
+                    receipt,
+                    proto::JoinProjectResponse {
+                        variant: Some(proto::join_project_response::Variant::Decline(
+                            proto::join_project_response::Decline {
+                                reason: proto::join_project_response::decline::Reason::Closed
+                                    as i32,
+                            },
+                        )),
+                    },
+                )?;
+            }
+        }
 
         self.update_user_contacts(user_id).await?;
         Ok(())
     }
 
-    async fn share_project(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::ShareProject>,
-        response: Response<proto::ShareProject>,
-    ) -> Result<()> {
-        let user_id = {
-            let mut state = self.store_mut().await;
-            state.share_project(request.payload.project_id, request.sender_id)?;
-            state.user_id_for_connection(request.sender_id)?
-        };
-        self.update_user_contacts(user_id).await?;
-        response.send(proto::Ack {})?;
-        Ok(())
-    }
-
     async fn update_user_contacts(self: &Arc<Server>, user_id: UserId) -> Result<()> {
         let contacts = self.app_state.db.get_contacts(user_id).await?;
         let store = self.store().await;
@@ -451,24 +484,6 @@ impl Server {
         Ok(())
     }
 
-    async fn unshare_project(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::UnshareProject>,
-    ) -> Result<()> {
-        let project_id = request.payload.project_id;
-        let project;
-        {
-            let mut state = self.store_mut().await;
-            project = state.unshare_project(project_id, request.sender_id)?;
-            broadcast(request.sender_id, project.connection_ids, |conn_id| {
-                self.peer
-                    .send(conn_id, proto::UnshareProject { project_id })
-            });
-        }
-        self.update_user_contacts(project.host_user_id).await?;
-        Ok(())
-    }
-
     async fn join_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::JoinProject>,
@@ -477,9 +492,12 @@ impl Server {
         let project_id = request.payload.project_id;
         let host_user_id;
         let guest_user_id;
+        let host_connection_id;
         {
             let state = self.store().await;
-            host_user_id = state.project(project_id)?.host_user_id;
+            let project = state.project(project_id)?;
+            host_user_id = project.host_user_id;
+            host_connection_id = project.host_connection_id;
             guest_user_id = state.user_id_for_connection(request.sender_id)?;
         };
 
@@ -492,22 +510,74 @@ impl Server {
             return Err(anyhow!("no such project"))?;
         }
 
+        self.store_mut().await.request_join_project(
+            guest_user_id,
+            project_id,
+            response.into_receipt(),
+        )?;
+        self.peer.send(
+            host_connection_id,
+            proto::RequestJoinProject {
+                project_id,
+                requester_id: guest_user_id.to_proto(),
+            },
+        )?;
+        Ok(())
+    }
+
+    async fn respond_to_join_project_request(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
+    ) -> Result<()> {
+        let host_user_id;
+
         {
-            let state = &mut *self.store_mut().await;
-            let joined = state.join_project(request.sender_id, guest_user_id, project_id)?;
-            let share = joined.project.share()?;
-            let peer_count = share.guests.len();
+            let mut state = self.store_mut().await;
+            let project_id = request.payload.project_id;
+            let project = state.project(project_id)?;
+            if project.host_connection_id != request.sender_id {
+                Err(anyhow!("no such connection"))?;
+            }
+
+            host_user_id = project.host_user_id;
+            let guest_user_id = UserId::from_proto(request.payload.requester_id);
+
+            if !request.payload.allow {
+                let receipts = state
+                    .deny_join_project_request(request.sender_id, guest_user_id, project_id)
+                    .ok_or_else(|| anyhow!("no such request"))?;
+                for receipt in receipts {
+                    self.peer.respond(
+                        receipt,
+                        proto::JoinProjectResponse {
+                            variant: Some(proto::join_project_response::Variant::Decline(
+                                proto::join_project_response::Decline {
+                                    reason: proto::join_project_response::decline::Reason::Declined
+                                        as i32,
+                                },
+                            )),
+                        },
+                    )?;
+                }
+                return Ok(());
+            }
+
+            let (receipts_with_replica_ids, project) = state
+                .accept_join_project_request(request.sender_id, guest_user_id, project_id)
+                .ok_or_else(|| anyhow!("no such request"))?;
+
+            let peer_count = project.guests.len();
             let mut collaborators = Vec::with_capacity(peer_count);
             collaborators.push(proto::Collaborator {
-                peer_id: joined.project.host_connection_id.0,
+                peer_id: project.host_connection_id.0,
                 replica_id: 0,
-                user_id: joined.project.host_user_id.to_proto(),
+                user_id: project.host_user_id.to_proto(),
             });
-            let worktrees = share
+            let worktrees = project
                 .worktrees
                 .iter()
                 .filter_map(|(id, shared_worktree)| {
-                    let worktree = joined.project.worktrees.get(&id)?;
+                    let worktree = project.worktrees.get(&id)?;
                     Some(proto::Worktree {
                         id: *id,
                         root_name: worktree.root_name.clone(),
@@ -521,9 +591,14 @@ impl Server {
                         scan_id: shared_worktree.scan_id,
                     })
                 })
-                .collect();
-            for (peer_conn_id, (peer_replica_id, peer_user_id)) in &share.guests {
-                if *peer_conn_id != request.sender_id {
+                .collect::<Vec<_>>();
+
+            // Add all guests other than the requesting user's own connections as collaborators
+            for (peer_conn_id, (peer_replica_id, peer_user_id)) in &project.guests {
+                if receipts_with_replica_ids
+                    .iter()
+                    .all(|(receipt, _)| receipt.sender_id != *peer_conn_id)
+                {
                     collaborators.push(proto::Collaborator {
                         peer_id: peer_conn_id.0,
                         replica_id: *peer_replica_id as u32,
@@ -531,30 +606,42 @@ impl Server {
                     });
                 }
             }
-            broadcast(
-                request.sender_id,
-                joined.project.connection_ids(),
-                |conn_id| {
-                    self.peer.send(
-                        conn_id,
-                        proto::AddProjectCollaborator {
-                            project_id,
-                            collaborator: Some(proto::Collaborator {
-                                peer_id: request.sender_id.0,
-                                replica_id: joined.replica_id as u32,
-                                user_id: guest_user_id.to_proto(),
-                            }),
-                        },
-                    )
-                },
-            );
-            response.send(proto::JoinProjectResponse {
-                worktrees,
-                replica_id: joined.replica_id as u32,
-                collaborators,
-                language_servers: joined.project.language_servers.clone(),
-            })?;
+
+            for conn_id in project.connection_ids() {
+                for (receipt, replica_id) in &receipts_with_replica_ids {
+                    if conn_id != receipt.sender_id {
+                        self.peer.send(
+                            conn_id,
+                            proto::AddProjectCollaborator {
+                                project_id,
+                                collaborator: Some(proto::Collaborator {
+                                    peer_id: receipt.sender_id.0,
+                                    replica_id: *replica_id as u32,
+                                    user_id: guest_user_id.to_proto(),
+                                }),
+                            },
+                        )?;
+                    }
+                }
+            }
+
+            for (receipt, replica_id) in receipts_with_replica_ids {
+                self.peer.respond(
+                    receipt,
+                    proto::JoinProjectResponse {
+                        variant: Some(proto::join_project_response::Variant::Accept(
+                            proto::join_project_response::Accept {
+                                worktrees: worktrees.clone(),
+                                replica_id: replica_id as u32,
+                                collaborators: collaborators.clone(),
+                                language_servers: project.language_servers.clone(),
+                            },
+                        )),
+                    },
+                )?;
+            }
         }
+
         self.update_user_contacts(host_user_id).await?;
         Ok(())
     }
@@ -567,17 +654,37 @@ impl Server {
         let project_id = request.payload.project_id;
         let project;
         {
-            let mut state = self.store_mut().await;
-            project = state.leave_project(sender_id, project_id)?;
-            broadcast(sender_id, project.connection_ids, |conn_id| {
+            let mut store = self.store_mut().await;
+            project = store.leave_project(sender_id, project_id)?;
+
+            if project.remove_collaborator {
+                broadcast(sender_id, project.connection_ids, |conn_id| {
+                    self.peer.send(
+                        conn_id,
+                        proto::RemoveProjectCollaborator {
+                            project_id,
+                            peer_id: sender_id.0,
+                        },
+                    )
+                });
+            }
+
+            if let Some(requester_id) = project.cancel_request {
                 self.peer.send(
-                    conn_id,
-                    proto::RemoveProjectCollaborator {
+                    project.host_connection_id,
+                    proto::JoinProjectRequestCancelled {
                         project_id,
-                        peer_id: sender_id.0,
+                        requester_id: requester_id.to_proto(),
                     },
-                )
-            });
+                )?;
+            }
+
+            if project.unshare {
+                self.peer.send(
+                    project.host_connection_id,
+                    proto::ProjectUnshared { project_id },
+                )?;
+            }
         }
         self.update_user_contacts(project.host_user_id).await?;
         Ok(())
@@ -603,6 +710,7 @@ impl Server {
                 Worktree {
                     root_name: request.payload.root_name.clone(),
                     visible: request.payload.visible,
+                    ..Default::default()
                 },
             )?;
 
@@ -1542,6 +1650,7 @@ mod tests {
     use settings::Settings;
     use sqlx::types::time::OffsetDateTime;
     use std::{
+        cell::RefCell,
         env,
         ops::Deref,
         path::{Path, PathBuf},
@@ -1564,7 +1673,12 @@ mod tests {
     }
 
     #[gpui::test(iterations = 10)]
-    async fn test_share_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    async fn test_share_project(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+        cx_b2: &mut TestAppContext,
+    ) {
         let (window_b, _) = cx_b.add_window(|_| EmptyView);
         let lang_registry = Arc::new(LanguageRegistry::test());
         let fs = FakeFs::new(cx_a.background());
@@ -1573,7 +1687,7 @@ mod tests {
         // Connect to a server as 2 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
         server
             .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
             .await;
@@ -1596,6 +1710,9 @@ mod tests {
                 cx,
             )
         });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
         let (worktree_a, _) = project_a
             .update(cx_a, |p, cx| {
                 p.find_or_create_local_worktree("/a", true, cx)
@@ -1606,20 +1723,10 @@ mod tests {
         worktree_a
             .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
             .await;
-        let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
-        project_a.update(cx_a, |p, cx| p.share(cx)).await.unwrap();
 
         // Join that project as client B
-        let project_b = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
+        let client_b_peer_id = client_b.peer_id;
+        let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
         let replica_id_b = project_b.read_with(cx_b, |project, _| {
             assert_eq!(
@@ -1636,7 +1743,7 @@ mod tests {
         project_a
             .condition(&cx_a, |tree, _| {
                 tree.collaborators()
-                    .get(&client_b.peer_id)
+                    .get(&client_b_peer_id)
                     .map_or(false, |collaborator| {
                         collaborator.replica_id == replica_id_b
                             && collaborator.user.github_login == "user_b"
@@ -1681,15 +1788,49 @@ mod tests {
         //     .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
         //     .await;
 
-        // Dropping the client B's project removes client B from client A's collaborators.
-        cx_b.update(move |_| drop(project_b));
-        project_a
-            .condition(&cx_a, |project, _| project.collaborators().is_empty())
-            .await;
+        // Client B can join again on a different window because they are already a participant.
+        let client_b2 = server.create_client(cx_b2, "user_b").await;
+        let project_b2 = Project::remote(
+            project_id,
+            client_b2.client.clone(),
+            client_b2.user_store.clone(),
+            lang_registry.clone(),
+            FakeFs::new(cx_b2.background()),
+            &mut cx_b2.to_async(),
+        )
+        .await
+        .unwrap();
+        deterministic.run_until_parked();
+        project_a.read_with(cx_a, |project, _| {
+            assert_eq!(project.collaborators().len(), 2);
+        });
+        project_b.read_with(cx_b, |project, _| {
+            assert_eq!(project.collaborators().len(), 2);
+        });
+        project_b2.read_with(cx_b2, |project, _| {
+            assert_eq!(project.collaborators().len(), 2);
+        });
+
+        // Dropping client B's first project removes only that from client A's collaborators.
+        cx_b.update(move |_| {
+            drop(client_b.project.take());
+            drop(project_b);
+        });
+        deterministic.run_until_parked();
+        project_a.read_with(cx_a, |project, _| {
+            assert_eq!(project.collaborators().len(), 1);
+        });
+        project_b2.read_with(cx_b2, |project, _| {
+            assert_eq!(project.collaborators().len(), 1);
+        });
     }
 
     #[gpui::test(iterations = 10)]
-    async fn test_unshare_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    async fn test_unshare_project(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+    ) {
         let lang_registry = Arc::new(LanguageRegistry::test());
         let fs = FakeFs::new(cx_a.background());
         cx_a.foreground().forbid_parking();
@@ -1697,7 +1838,7 @@ mod tests {
         // Connect to a server as 2 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
         server
             .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
             .await;
@@ -1729,54 +1870,27 @@ mod tests {
         worktree_a
             .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
             .await;
-        let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
         let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id());
-        project_a.update(cx_a, |p, cx| p.share(cx)).await.unwrap();
-        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
         // Join that project as client B
-        let project_b = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
+        let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
         project_b
             .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
             .await
             .unwrap();
 
-        // Unshare the project as client A
-        project_a.update(cx_a, |project, cx| project.unshare(cx));
-        project_b
-            .condition(cx_b, |project, _| project.is_read_only())
-            .await;
-        assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
+        // When client B leaves the project, it gets automatically unshared.
         cx_b.update(|_| {
+            drop(client_b.project.take());
             drop(project_b);
         });
+        deterministic.run_until_parked();
+        assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
 
-        // Share the project again and ensure guests can still join.
-        project_a
-            .update(cx_a, |project, cx| project.share(cx))
-            .await
-            .unwrap();
+        // When client B joins again, the project gets re-shared.
+        let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
         assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
-
-        let project_b2 = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
         project_b2
             .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
             .await
@@ -1784,17 +1898,27 @@ mod tests {
     }
 
     #[gpui::test(iterations = 10)]
-    async fn test_host_disconnect(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    async fn test_host_disconnect(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+        cx_c: &mut TestAppContext,
+    ) {
         let lang_registry = Arc::new(LanguageRegistry::test());
         let fs = FakeFs::new(cx_a.background());
         cx_a.foreground().forbid_parking();
 
-        // Connect to a server as 2 clients.
+        // Connect to a server as 3 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
+        let client_c = server.create_client(cx_c, "user_c").await;
         server
-            .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+            .make_contacts(vec![
+                (&client_a, cx_a),
+                (&client_b, cx_b),
+                (&client_c, cx_c),
+            ])
             .await;
 
         // Share a project as client A
@@ -1815,6 +1939,9 @@ mod tests {
                 cx,
             )
         });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
         let (worktree_a, _) = project_a
             .update(cx_a, |p, cx| {
                 p.find_or_create_local_worktree("/a", true, cx)
@@ -1824,27 +1951,35 @@ mod tests {
         worktree_a
             .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
             .await;
-        let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
         let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id());
-        project_a.update(cx_a, |p, cx| p.share(cx)).await.unwrap();
-        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
         // Join that project as client B
-        let project_b = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
+        let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
         project_b
             .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
             .await
             .unwrap();
 
+        // Request to join that project as client C
+        let project_c = cx_c.spawn(|mut cx| {
+            let client = client_c.client.clone();
+            let user_store = client_c.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+        deterministic.run_until_parked();
+
         // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
         server.disconnect_client(client_a.current_user_id(cx_a));
         cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
@@ -1859,31 +1994,213 @@ mod tests {
         cx_b.update(|_| {
             drop(project_b);
         });
+        assert!(matches!(
+            project_c.await.unwrap_err(),
+            project::JoinProjectError::HostWentOffline
+        ));
 
-        // Await reconnection
-        let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
+        // Ensure guests can still join.
+        let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
+        project_b2
+            .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+            .await
+            .unwrap();
+    }
 
-        // Share the project again and ensure guests can still join.
-        project_a
-            .update(cx_a, |project, cx| project.share(cx))
+    #[gpui::test(iterations = 10)]
+    async fn test_decline_join_request(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+    ) {
+        let lang_registry = Arc::new(LanguageRegistry::test());
+        let fs = FakeFs::new(cx_a.background());
+        cx_a.foreground().forbid_parking();
+
+        // Connect to a server as 2 clients.
+        let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+        let client_a = server.create_client(cx_a, "user_a").await;
+        let client_b = server.create_client(cx_b, "user_b").await;
+        server
+            .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+            .await;
+
+        // Share a project as client A
+        fs.insert_tree("/a", json!({})).await;
+        let project_a = cx_a.update(|cx| {
+            Project::local(
+                client_a.clone(),
+                client_a.user_store.clone(),
+                lang_registry.clone(),
+                fs.clone(),
+                cx,
+            )
+        });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
+        let (worktree_a, _) = project_a
+            .update(cx_a, |p, cx| {
+                p.find_or_create_local_worktree("/a", true, cx)
+            })
             .await
             .unwrap();
-        assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
+        worktree_a
+            .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+            .await;
 
-        let project_b2 = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
-        project_b2
-            .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+        // Request to join that project as client B
+        let project_b = cx_b.spawn(|mut cx| {
+            let client = client_b.client.clone();
+            let user_store = client_b.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+        deterministic.run_until_parked();
+        project_a.update(cx_a, |project, cx| {
+            project.respond_to_join_request(client_b.user_id().unwrap(), false, cx)
+        });
+        assert!(matches!(
+            project_b.await.unwrap_err(),
+            project::JoinProjectError::HostDeclined
+        ));
+
+        // Request to join the project again as client B
+        let project_b = cx_b.spawn(|mut cx| {
+            let client = client_b.client.clone();
+            let user_store = client_b.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+
+        // Close the project on the host
+        deterministic.run_until_parked();
+        cx_a.update(|_| drop(project_a));
+        deterministic.run_until_parked();
+        assert!(matches!(
+            project_b.await.unwrap_err(),
+            project::JoinProjectError::HostClosedProject
+        ));
+    }
+
+    #[gpui::test(iterations = 10)]
+    async fn test_cancel_join_request(
+        deterministic: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+    ) {
+        let lang_registry = Arc::new(LanguageRegistry::test());
+        let fs = FakeFs::new(cx_a.background());
+        cx_a.foreground().forbid_parking();
+
+        // Connect to a server as 2 clients.
+        let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+        let client_a = server.create_client(cx_a, "user_a").await;
+        let client_b = server.create_client(cx_b, "user_b").await;
+        server
+            .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+            .await;
+
+        // Share a project as client A
+        fs.insert_tree("/a", json!({})).await;
+        let project_a = cx_a.update(|cx| {
+            Project::local(
+                client_a.clone(),
+                client_a.user_store.clone(),
+                lang_registry.clone(),
+                fs.clone(),
+                cx,
+            )
+        });
+        let project_id = project_a
+            .read_with(cx_a, |project, _| project.next_remote_id())
+            .await;
+
+        let project_a_events = Rc::new(RefCell::new(Vec::new()));
+        let user_b = client_a
+            .user_store
+            .update(cx_a, |store, cx| {
+                store.fetch_user(client_b.user_id().unwrap(), cx)
+            })
             .await
             .unwrap();
+        project_a.update(cx_a, {
+            let project_a_events = project_a_events.clone();
+            move |_, cx| {
+                cx.subscribe(&cx.handle(), move |_, _, event, _| {
+                    project_a_events.borrow_mut().push(event.clone());
+                })
+                .detach();
+            }
+        });
+
+        let (worktree_a, _) = project_a
+            .update(cx_a, |p, cx| {
+                p.find_or_create_local_worktree("/a", true, cx)
+            })
+            .await
+            .unwrap();
+        worktree_a
+            .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
+            .await;
+
+        // Request to join that project as client B
+        let project_b = cx_b.spawn(|mut cx| {
+            let client = client_b.client.clone();
+            let user_store = client_b.user_store.clone();
+            let lang_registry = lang_registry.clone();
+            async move {
+                Project::remote(
+                    project_id,
+                    client,
+                    user_store,
+                    lang_registry.clone(),
+                    FakeFs::new(cx.background()),
+                    &mut cx,
+                )
+                .await
+            }
+        });
+        deterministic.run_until_parked();
+        assert_eq!(
+            &*project_a_events.borrow(),
+            &[project::Event::ContactRequestedJoin(user_b.clone())]
+        );
+        project_a_events.borrow_mut().clear();
+
+        // Cancel the join request by leaving the project
+        client_b
+            .client
+            .send(proto::LeaveProject { project_id })
+            .unwrap();
+        drop(project_b);
+
+        deterministic.run_until_parked();
+        assert_eq!(
+            &*project_a_events.borrow(),
+            &[project::Event::ContactCancelledJoinRequest(user_b.clone())]
+        );
     }
 
     #[gpui::test(iterations = 10)]
@@ -1899,8 +2216,8 @@ mod tests {
         // Connect to a server as 3 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
-        let client_c = server.create_client(cx_c, "user_c").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_c = server.create_client(cx_c, "user_c").await;
         server
             .make_contacts(vec![
                 (&client_a, cx_a),
@@ -1936,31 +2253,11 @@ mod tests {
         worktree_a
             .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
             .await;
-        let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
         let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id());
-        project_a.update(cx_a, |p, cx| p.share(cx)).await.unwrap();
 
         // Join that worktree as clients B and C.
-        let project_b = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
-        let project_c = Project::remote(
-            project_id,
-            client_c.clone(),
-            client_c.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_c.to_async(),
-        )
-        .await
-        .unwrap();
+        let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+        let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await;
         let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap());
         let worktree_c = project_c.read_with(cx_c, |p, cx| p.worktrees(cx).next().unwrap());
 
@@ -2099,13 +2396,7 @@ mod tests {
         .await;
 
         let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
-        let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
-        project_a
-            .update(cx_a, |project, cx| project.share(cx))
-            .await
-            .unwrap();
-
-        let project_b = client_b.build_remote_project(project_id, cx_b).await;
+        let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
         let worktree_a =
             project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
@@ -2252,7 +2543,7 @@ mod tests {
         // Connect to a server as 2 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
         let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
         server
             .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
             .await;

crates/collab/src/rpc/store.rs 🔗

@@ -1,8 +1,8 @@
 use crate::db::{self, ChannelId, UserId};
 use anyhow::{anyhow, Result};
-use collections::{BTreeMap, HashMap, HashSet};
-use rpc::{proto, ConnectionId};
-use std::{collections::hash_map, path::PathBuf};
+use collections::{hash_map::Entry, BTreeMap, HashMap, HashSet};
+use rpc::{proto, ConnectionId, Receipt};
+use std::{collections::hash_map, mem, path::PathBuf};
 use tracing::instrument;
 
 #[derive(Default)]
@@ -17,31 +17,24 @@ pub struct Store {
 struct ConnectionState {
     user_id: UserId,
     projects: HashSet<u64>,
+    requested_projects: HashSet<u64>,
     channels: HashSet<ChannelId>,
 }
 
 pub struct Project {
     pub host_connection_id: ConnectionId,
     pub host_user_id: UserId,
-    pub share: Option<ProjectShare>,
+    pub guests: HashMap<ConnectionId, (ReplicaId, UserId)>,
+    pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+    pub active_replica_ids: HashSet<ReplicaId>,
     pub worktrees: HashMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
 }
 
+#[derive(Default)]
 pub struct Worktree {
     pub root_name: String,
     pub visible: bool,
-}
-
-#[derive(Default)]
-pub struct ProjectShare {
-    pub guests: HashMap<ConnectionId, (ReplicaId, UserId)>,
-    pub active_replica_ids: HashSet<ReplicaId>,
-    pub worktrees: HashMap<u64, WorktreeShare>,
-}
-
-#[derive(Default)]
-pub struct WorktreeShare {
     pub entries: HashMap<u64, proto::Entry>,
     pub diagnostic_summaries: BTreeMap<PathBuf, proto::DiagnosticSummary>,
     pub scan_id: u64,
@@ -58,25 +51,17 @@ pub type ReplicaId = u16;
 pub struct RemovedConnectionState {
     pub user_id: UserId,
     pub hosted_projects: HashMap<u64, Project>,
-    pub guest_project_ids: HashMap<u64, Vec<ConnectionId>>,
+    pub guest_project_ids: HashSet<u64>,
     pub contact_ids: HashSet<UserId>,
 }
 
-pub struct JoinedProject<'a> {
-    pub replica_id: ReplicaId,
-    pub project: &'a Project,
-}
-
-pub struct SharedProject {}
-
-pub struct UnsharedProject {
-    pub connection_ids: Vec<ConnectionId>,
-    pub host_user_id: UserId,
-}
-
 pub struct LeftProject {
-    pub connection_ids: Vec<ConnectionId>,
     pub host_user_id: UserId,
+    pub host_connection_id: ConnectionId,
+    pub connection_ids: Vec<ConnectionId>,
+    pub remove_collaborator: bool,
+    pub cancel_request: Option<UserId>,
+    pub unshare: bool,
 }
 
 #[derive(Copy, Clone)]
@@ -93,7 +78,7 @@ impl Store {
         let mut shared_projects = 0;
         for project in self.projects.values() {
             registered_projects += 1;
-            if project.share.is_some() {
+            if !project.guests.is_empty() {
                 shared_projects += 1;
             }
         }
@@ -112,6 +97,7 @@ impl Store {
             ConnectionState {
                 user_id,
                 projects: Default::default(),
+                requested_projects: Default::default(),
                 channels: Default::default(),
             },
         );
@@ -126,39 +112,40 @@ impl Store {
         &mut self,
         connection_id: ConnectionId,
     ) -> Result<RemovedConnectionState> {
-        let connection = if let Some(connection) = self.connections.remove(&connection_id) {
-            connection
-        } else {
-            return Err(anyhow!("no such connection"))?;
-        };
+        let connection = self
+            .connections
+            .get_mut(&connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
 
-        for channel_id in &connection.channels {
-            if let Some(channel) = self.channels.get_mut(&channel_id) {
-                channel.connection_ids.remove(&connection_id);
-            }
-        }
+        let user_id = connection.user_id;
+        let connection_projects = mem::take(&mut connection.projects);
+        let connection_channels = mem::take(&mut connection.channels);
 
-        let user_connections = self
-            .connections_by_user_id
-            .get_mut(&connection.user_id)
-            .unwrap();
-        user_connections.remove(&connection_id);
-        if user_connections.is_empty() {
-            self.connections_by_user_id.remove(&connection.user_id);
+        let mut result = RemovedConnectionState::default();
+        result.user_id = user_id;
+
+        // Leave all channels.
+        for channel_id in connection_channels {
+            self.leave_channel(connection_id, channel_id);
         }
 
-        let mut result = RemovedConnectionState::default();
-        result.user_id = connection.user_id;
-        for project_id in connection.projects.clone() {
+        // Unregister and leave all projects.
+        for project_id in connection_projects {
             if let Ok(project) = self.unregister_project(project_id, connection_id) {
                 result.hosted_projects.insert(project_id, project);
-            } else if let Ok(project) = self.leave_project(connection_id, project_id) {
-                result
-                    .guest_project_ids
-                    .insert(project_id, project.connection_ids);
+            } else if self.leave_project(connection_id, project_id).is_ok() {
+                result.guest_project_ids.insert(project_id);
             }
         }
 
+        let user_connections = self.connections_by_user_id.get_mut(&user_id).unwrap();
+        user_connections.remove(&connection_id);
+        if user_connections.is_empty() {
+            self.connections_by_user_id.remove(&user_id);
+        }
+
+        self.connections.remove(&connection_id).unwrap();
+
         Ok(result)
     }
 
@@ -275,18 +262,15 @@ impl Store {
                 if project.host_user_id == user_id {
                     metadata.push(proto::ProjectMetadata {
                         id: project_id,
-                        is_shared: project.share.is_some(),
                         worktree_root_names: project
                             .worktrees
                             .values()
                             .map(|worktree| worktree.root_name.clone())
                             .collect(),
                         guests: project
-                            .share
-                            .iter()
-                            .flat_map(|share| {
-                                share.guests.values().map(|(_, user_id)| user_id.to_proto())
-                            })
+                            .guests
+                            .values()
+                            .map(|(_, user_id)| user_id.to_proto())
                             .collect(),
                     });
                 }
@@ -307,7 +291,9 @@ impl Store {
             Project {
                 host_connection_id,
                 host_user_id,
-                share: None,
+                guests: Default::default(),
+                join_requests: Default::default(),
+                active_replica_ids: Default::default(),
                 worktrees: Default::default(),
                 language_servers: Default::default(),
             },
@@ -332,10 +318,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
             project.worktrees.insert(worktree_id, worktree);
-            if let Ok(share) = project.share_mut() {
-                share.worktrees.insert(worktree_id, Default::default());
-            }
-
             Ok(())
         } else {
             Err(anyhow!("no such project"))?
@@ -356,10 +338,22 @@ impl Store {
                         host_connection.projects.remove(&project_id);
                     }
 
-                    if let Some(share) = &project.share {
-                        for guest_connection in share.guests.keys() {
-                            if let Some(connection) = self.connections.get_mut(&guest_connection) {
-                                connection.projects.remove(&project_id);
+                    for guest_connection in project.guests.keys() {
+                        if let Some(connection) = self.connections.get_mut(&guest_connection) {
+                            connection.projects.remove(&project_id);
+                        }
+                    }
+
+                    for requester_user_id in project.join_requests.keys() {
+                        if let Some(requester_connection_ids) =
+                            self.connections_by_user_id.get_mut(&requester_user_id)
+                        {
+                            for requester_connection_id in requester_connection_ids.iter() {
+                                if let Some(requester_connection) =
+                                    self.connections.get_mut(requester_connection_id)
+                                {
+                                    requester_connection.requested_projects.remove(&project_id);
+                                }
                             }
                         }
                     }
@@ -391,64 +385,7 @@ impl Store {
             .worktrees
             .remove(&worktree_id)
             .ok_or_else(|| anyhow!("no such worktree"))?;
-
-        let mut guest_connection_ids = Vec::new();
-        if let Ok(share) = project.share_mut() {
-            guest_connection_ids.extend(share.guests.keys());
-            share.worktrees.remove(&worktree_id);
-        }
-
-        Ok((worktree, guest_connection_ids))
-    }
-
-    pub fn share_project(
-        &mut self,
-        project_id: u64,
-        connection_id: ConnectionId,
-    ) -> Result<SharedProject> {
-        if let Some(project) = self.projects.get_mut(&project_id) {
-            if project.host_connection_id == connection_id {
-                let mut share = ProjectShare::default();
-                for worktree_id in project.worktrees.keys() {
-                    share.worktrees.insert(*worktree_id, Default::default());
-                }
-                project.share = Some(share);
-                return Ok(SharedProject {});
-            }
-        }
-        Err(anyhow!("no such project"))?
-    }
-
-    pub fn unshare_project(
-        &mut self,
-        project_id: u64,
-        acting_connection_id: ConnectionId,
-    ) -> Result<UnsharedProject> {
-        let project = if let Some(project) = self.projects.get_mut(&project_id) {
-            project
-        } else {
-            return Err(anyhow!("no such project"))?;
-        };
-
-        if project.host_connection_id != acting_connection_id {
-            return Err(anyhow!("not your project"))?;
-        }
-
-        let connection_ids = project.connection_ids();
-        if let Some(share) = project.share.take() {
-            for connection_id in share.guests.into_keys() {
-                if let Some(connection) = self.connections.get_mut(&connection_id) {
-                    connection.projects.remove(&project_id);
-                }
-            }
-
-            Ok(UnsharedProject {
-                connection_ids,
-                host_user_id: project.host_user_id,
-            })
-        } else {
-            Err(anyhow!("project is not shared"))?
-        }
+        Ok((worktree, project.guest_connection_ids()))
     }
 
     pub fn update_diagnostic_summary(
@@ -464,7 +401,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
             let worktree = project
-                .share_mut()?
                 .worktrees
                 .get_mut(&worktree_id)
                 .ok_or_else(|| anyhow!("no such worktree"))?;
@@ -495,35 +431,77 @@ impl Store {
         Err(anyhow!("no such project"))?
     }
 
-    pub fn join_project(
+    pub fn request_join_project(
         &mut self,
-        connection_id: ConnectionId,
-        user_id: UserId,
+        requester_id: UserId,
         project_id: u64,
-    ) -> Result<JoinedProject> {
+        receipt: Receipt<proto::JoinProject>,
+    ) -> Result<()> {
         let connection = self
             .connections
-            .get_mut(&connection_id)
+            .get_mut(&receipt.sender_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
+        connection.requested_projects.insert(project_id);
+        project
+            .join_requests
+            .entry(requester_id)
+            .or_default()
+            .push(receipt);
+        Ok(())
+    }
 
-        let share = project.share_mut()?;
-        connection.projects.insert(project_id);
+    pub fn deny_join_project_request(
+        &mut self,
+        responder_connection_id: ConnectionId,
+        requester_id: UserId,
+        project_id: u64,
+    ) -> Option<Vec<Receipt<proto::JoinProject>>> {
+        let project = self.projects.get_mut(&project_id)?;
+        if responder_connection_id != project.host_connection_id {
+            return None;
+        }
 
-        let mut replica_id = 1;
-        while share.active_replica_ids.contains(&replica_id) {
-            replica_id += 1;
+        let receipts = project.join_requests.remove(&requester_id)?;
+        for receipt in &receipts {
+            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
+            requester_connection.requested_projects.remove(&project_id);
         }
-        share.active_replica_ids.insert(replica_id);
-        share.guests.insert(connection_id, (replica_id, user_id));
+        Some(receipts)
+    }
 
-        Ok(JoinedProject {
-            replica_id,
-            project: &self.projects[&project_id],
-        })
+    pub fn accept_join_project_request(
+        &mut self,
+        responder_connection_id: ConnectionId,
+        requester_id: UserId,
+        project_id: u64,
+    ) -> Option<(Vec<(Receipt<proto::JoinProject>, ReplicaId)>, &Project)> {
+        let project = self.projects.get_mut(&project_id)?;
+        if responder_connection_id != project.host_connection_id {
+            return None;
+        }
+
+        let receipts = project.join_requests.remove(&requester_id)?;
+        let mut receipts_with_replica_ids = Vec::new();
+        for receipt in receipts {
+            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
+            requester_connection.requested_projects.remove(&project_id);
+            requester_connection.projects.insert(project_id);
+            let mut replica_id = 1;
+            while project.active_replica_ids.contains(&replica_id) {
+                replica_id += 1;
+            }
+            project.active_replica_ids.insert(replica_id);
+            project
+                .guests
+                .insert(receipt.sender_id, (replica_id, requester_id));
+            receipts_with_replica_ids.push((receipt, replica_id));
+        }
+
+        Some((receipts_with_replica_ids, project))
     }
 
     pub fn leave_project(
@@ -531,27 +509,48 @@ impl Store {
         connection_id: ConnectionId,
         project_id: u64,
     ) -> Result<LeftProject> {
+        let user_id = self.user_id_for_connection(connection_id)?;
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
-        let share = project
-            .share
-            .as_mut()
-            .ok_or_else(|| anyhow!("project is not shared"))?;
-        let (replica_id, _) = share
-            .guests
-            .remove(&connection_id)
-            .ok_or_else(|| anyhow!("cannot leave a project before joining it"))?;
-        share.active_replica_ids.remove(&replica_id);
+
+        // If the connection leaving the project is a collaborator, remove it.
+        let remove_collaborator =
+            if let Some((replica_id, _)) = project.guests.remove(&connection_id) {
+                project.active_replica_ids.remove(&replica_id);
+                true
+            } else {
+                false
+            };
+
+        // If the connection leaving the project has a pending request, remove it.
+        // If that user has no other pending requests on other connections, indicate that the request should be cancelled.
+        let mut cancel_request = None;
+        if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) {
+            entry
+                .get_mut()
+                .retain(|receipt| receipt.sender_id != connection_id);
+            if entry.get().is_empty() {
+                entry.remove();
+                cancel_request = Some(user_id);
+            }
+        }
 
         if let Some(connection) = self.connections.get_mut(&connection_id) {
             connection.projects.remove(&project_id);
         }
 
+        let connection_ids = project.connection_ids();
+        let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty();
+
         Ok(LeftProject {
-            connection_ids: project.connection_ids(),
+            host_connection_id: project.host_connection_id,
             host_user_id: project.host_user_id,
+            connection_ids,
+            cancel_request,
+            unshare,
+            remove_collaborator,
         })
     }
 
@@ -566,7 +565,6 @@ impl Store {
     ) -> Result<Vec<ConnectionId>> {
         let project = self.write_project(project_id, connection_id)?;
         let worktree = project
-            .share_mut()?
             .worktrees
             .get_mut(&worktree_id)
             .ok_or_else(|| anyhow!("no such worktree"))?;
@@ -611,12 +609,7 @@ impl Store {
             .get(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id
-            || project
-                .share
-                .as_ref()
-                .ok_or_else(|| anyhow!("project is not shared"))?
-                .guests
-                .contains_key(&connection_id)
+            || project.guests.contains_key(&connection_id)
         {
             Ok(project)
         } else {
@@ -634,12 +627,7 @@ impl Store {
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id
-            || project
-                .share
-                .as_ref()
-                .ok_or_else(|| anyhow!("project is not shared"))?
-                .guests
-                .contains_key(&connection_id)
+            || project.guests.contains_key(&connection_id)
         {
             Ok(project)
         } else {
@@ -653,28 +641,21 @@ impl Store {
             for project_id in &connection.projects {
                 let project = &self.projects.get(&project_id).unwrap();
                 if project.host_connection_id != *connection_id {
-                    assert!(project
-                        .share
-                        .as_ref()
-                        .unwrap()
-                        .guests
-                        .contains_key(connection_id));
+                    assert!(project.guests.contains_key(connection_id));
                 }
 
-                if let Some(share) = project.share.as_ref() {
-                    for (worktree_id, worktree) in share.worktrees.iter() {
-                        let mut paths = HashMap::default();
-                        for entry in worktree.entries.values() {
-                            let prev_entry = paths.insert(&entry.path, entry);
-                            assert_eq!(
-                                prev_entry,
-                                None,
-                                "worktree {:?}, duplicate path for entries {:?} and {:?}",
-                                worktree_id,
-                                prev_entry.unwrap(),
-                                entry
-                            );
-                        }
+                for (worktree_id, worktree) in project.worktrees.iter() {
+                    let mut paths = HashMap::default();
+                    for entry in worktree.entries.values() {
+                        let prev_entry = paths.insert(&entry.path, entry);
+                        assert_eq!(
+                            prev_entry,
+                            None,
+                            "worktree {:?}, duplicate path for entries {:?} and {:?}",
+                            worktree_id,
+                            prev_entry.unwrap(),
+                            entry
+                        );
                     }
                 }
             }
@@ -702,21 +683,19 @@ impl Store {
             let host_connection = self.connections.get(&project.host_connection_id).unwrap();
             assert!(host_connection.projects.contains(project_id));
 
-            if let Some(share) = &project.share {
-                for guest_connection_id in share.guests.keys() {
-                    let guest_connection = self.connections.get(guest_connection_id).unwrap();
-                    assert!(guest_connection.projects.contains(project_id));
-                }
-                assert_eq!(share.active_replica_ids.len(), share.guests.len(),);
-                assert_eq!(
-                    share.active_replica_ids,
-                    share
-                        .guests
-                        .values()
-                        .map(|(replica_id, _)| *replica_id)
-                        .collect::<HashSet<_>>(),
-                );
+            for guest_connection_id in project.guests.keys() {
+                let guest_connection = self.connections.get(guest_connection_id).unwrap();
+                assert!(guest_connection.projects.contains(project_id));
             }
+            assert_eq!(project.active_replica_ids.len(), project.guests.len(),);
+            assert_eq!(
+                project.active_replica_ids,
+                project
+                    .guests
+                    .values()
+                    .map(|(replica_id, _)| *replica_id)
+                    .collect::<HashSet<_>>(),
+            );
         }
 
         for (channel_id, channel) in &self.channels {
@@ -730,38 +709,15 @@ impl Store {
 
 impl Project {
     pub fn guest_connection_ids(&self) -> Vec<ConnectionId> {
-        if let Some(share) = &self.share {
-            share.guests.keys().copied().collect()
-        } else {
-            Vec::new()
-        }
+        self.guests.keys().copied().collect()
     }
 
     pub fn connection_ids(&self) -> Vec<ConnectionId> {
-        if let Some(share) = &self.share {
-            share
-                .guests
-                .keys()
-                .copied()
-                .chain(Some(self.host_connection_id))
-                .collect()
-        } else {
-            vec![self.host_connection_id]
-        }
-    }
-
-    pub fn share(&self) -> Result<&ProjectShare> {
-        Ok(self
-            .share
-            .as_ref()
-            .ok_or_else(|| anyhow!("worktree is not shared"))?)
-    }
-
-    fn share_mut(&mut self) -> Result<&mut ProjectShare> {
-        Ok(self
-            .share
-            .as_mut()
-            .ok_or_else(|| anyhow!("worktree is not shared"))?)
+        self.guests
+            .keys()
+            .copied()
+            .chain(Some(self.host_connection_id))
+            .collect()
     }
 }
 

crates/contacts_panel/Cargo.toml 🔗

@@ -13,6 +13,7 @@ editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 picker = { path = "../picker" }
+project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/contacts_panel/src/contact_notification.rs 🔗

@@ -1,13 +1,11 @@
+use crate::notifications::render_user_notification;
 use client::{ContactEvent, ContactEventKind, UserStore};
 use gpui::{
-    elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle,
-    MutableAppContext, RenderContext, View, ViewContext,
+    elements::*, impl_internal_actions, Entity, ModelHandle, MutableAppContext, RenderContext,
+    View, ViewContext,
 };
-use settings::Settings;
 use workspace::Notification;
 
-use crate::render_icon_button;
-
 impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
 
 pub fn init(cx: &mut MutableAppContext) {
@@ -33,9 +31,6 @@ pub enum Event {
     Dismiss,
 }
 
-enum Decline {}
-enum Accept {}
-
 impl Entity for ContactNotification {
     type Event = Event;
 }
@@ -47,8 +42,40 @@ impl View for ContactNotification {
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         match self.event.kind {
-            ContactEventKind::Requested => self.render_incoming_request(cx),
-            ContactEventKind::Accepted => self.render_acceptance(cx),
+            ContactEventKind::Requested => render_user_notification(
+                self.event.user.clone(),
+                "wants to add you as a contact",
+                Some("They won't know if you decline."),
+                RespondToContactRequest {
+                    user_id: self.event.user.id,
+                    accept: false,
+                },
+                vec![
+                    (
+                        "Decline",
+                        Box::new(RespondToContactRequest {
+                            user_id: self.event.user.id,
+                            accept: false,
+                        }),
+                    ),
+                    (
+                        "Accept",
+                        Box::new(RespondToContactRequest {
+                            user_id: self.event.user.id,
+                            accept: true,
+                        }),
+                    ),
+                ],
+                cx,
+            ),
+            ContactEventKind::Accepted => render_user_notification(
+                self.event.user.clone(),
+                "accepted your contact request",
+                None,
+                Dismiss(self.event.user.id),
+                vec![],
+                cx,
+            ),
             _ => unreachable!(),
         }
     }
@@ -82,138 +109,6 @@ impl ContactNotification {
         Self { event, user_store }
     }
 
-    fn render_incoming_request(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contact_notification;
-        let user = &self.event.user;
-        let user_id = user.id;
-
-        Flex::column()
-            .with_child(self.render_header("wants to add you as a contact.", theme, cx))
-            .with_child(
-                Label::new(
-                    "They won't know if you decline.".to_string(),
-                    theme.body_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.body_message.container)
-                .boxed(),
-            )
-            .with_child(
-                Flex::row()
-                    .with_child(
-                        MouseEventHandler::new::<Decline, _, _>(
-                            self.event.user.id as usize,
-                            cx,
-                            |state, _| {
-                                let button = theme.button.style_for(state, false);
-                                Label::new("Decline".to_string(), button.text.clone())
-                                    .contained()
-                                    .with_style(button.container)
-                                    .boxed()
-                            },
-                        )
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: false,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |state, _| {
-                            let button = theme.button.style_for(state, false);
-                            Label::new("Accept".to_string(), button.text.clone())
-                                .contained()
-                                .with_style(button.container)
-                                .boxed()
-                        })
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: true,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .aligned()
-                    .right()
-                    .boxed(),
-            )
-            .contained()
-            .boxed()
-    }
-
-    fn render_acceptance(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contact_notification;
-
-        self.render_header("accepted your contact request", theme, cx)
-    }
-
-    fn render_header(
-        &self,
-        message: &'static str,
-        theme: &theme::ContactNotification,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let user = &self.event.user;
-        let user_id = user.id;
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.header_avatar)
-                    .aligned()
-                    .constrained()
-                    .with_height(
-                        cx.font_cache()
-                            .line_height(theme.header_message.text.font_size),
-                    )
-                    .aligned()
-                    .top()
-                    .boxed()
-            }))
-            .with_child(
-                Text::new(
-                    format!("{} {}", user.github_login, message),
-                    theme.header_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.header_message.container)
-                .aligned()
-                .top()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            )
-            .with_child(
-                MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |state, _| {
-                    render_icon_button(
-                        theme.dismiss_button.style_for(state, false),
-                        "icons/decline.svg",
-                    )
-                    .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .with_padding(Padding::uniform(5.))
-                .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id)))
-                .aligned()
-                .constrained()
-                .with_height(
-                    cx.font_cache()
-                        .line_height(theme.header_message.text.font_size),
-                )
-                .aligned()
-                .top()
-                .flex_float()
-                .boxed(),
-            )
-            .named("contact notification header")
-    }
-
     fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
         self.user_store.update(cx, |store, cx| {
             store

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,5 +1,7 @@
 mod contact_finder;
 mod contact_notification;
+mod join_project_notification;
+mod notifications;
 
 use client::{Contact, ContactEventKind, User, UserStore};
 use contact_notification::ContactNotification;
@@ -13,6 +15,7 @@ use gpui::{
     AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
     RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
+use join_project_notification::JoinProjectNotification;
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
@@ -76,6 +79,7 @@ pub struct RespondToContactRequest {
 pub fn init(cx: &mut MutableAppContext) {
     contact_finder::init(cx);
     contact_notification::init(cx);
+    join_project_notification::init(cx);
     cx.add_action(ContactsPanel::request_contact);
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
@@ -118,6 +122,37 @@ impl ContactsPanel {
         })
         .detach();
 
+        cx.defer({
+            let workspace = workspace.clone();
+            move |_, cx| {
+                if let Some(workspace_handle) = workspace.upgrade(cx) {
+                    cx.subscribe(&workspace_handle.read(cx).project().clone(), {
+                        let workspace = workspace.clone();
+                        move |_, project, event, cx| match event {
+                            project::Event::ContactRequestedJoin(user) => {
+                                if let Some(workspace) = workspace.upgrade(cx) {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.show_notification(
+                                            cx.add_view(|cx| {
+                                                JoinProjectNotification::new(
+                                                    project,
+                                                    user.clone(),
+                                                    cx,
+                                                )
+                                            }),
+                                            cx,
+                                        )
+                                    });
+                                }
+                            }
+                            _ => {}
+                        }
+                    })
+                    .detach();
+                }
+            }
+        });
+
         cx.subscribe(&app_state.user_store, {
             let user_store = app_state.user_store.downgrade();
             move |_, _, event, cx| {
@@ -305,22 +340,16 @@ impl ContactsPanel {
     fn render_contact_project(
         contact: Arc<Contact>,
         current_user_id: Option<u64>,
-        project_ix: usize,
+        project_index: usize,
         app_state: Arc<AppState>,
         theme: &theme::ContactsPanel,
         is_last_project: bool,
         is_selected: bool,
         cx: &mut LayoutContext,
     ) -> ElementBox {
-        let project = &contact.projects[project_ix];
+        let project = &contact.projects[project_index];
         let project_id = project.id;
         let is_host = Some(contact.user.id) == current_user_id;
-        let is_guest = !is_host
-            && project
-                .guests
-                .iter()
-                .any(|guest| Some(guest.id) == current_user_id);
-        let is_shared = project.is_shared;
 
         let font_cache = cx.font_cache();
         let host_avatar_height = theme
@@ -328,7 +357,7 @@ impl ContactsPanel {
             .width
             .or(theme.contact_avatar.height)
             .unwrap_or(0.);
-        let row = &theme.unshared_project_row.default;
+        let row = &theme.project_row.default;
         let tree_branch = theme.tree_branch.clone();
         let line_height = row.name.text.line_height(font_cache);
         let cap_height = row.name.text.cap_height(font_cache);
@@ -337,12 +366,7 @@ impl ContactsPanel {
 
         MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
             let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = if project.is_shared {
-                &theme.shared_project_row
-            } else {
-                &theme.unshared_project_row
-            }
-            .style_for(mouse_state, is_selected);
+            let row = theme.project_row.style_for(mouse_state, is_selected);
 
             Flex::row()
                 .with_child(
@@ -412,15 +436,16 @@ impl ContactsPanel {
                 .with_style(row.container)
                 .boxed()
         })
-        .with_cursor_style(if !is_host && is_shared {
+        .with_cursor_style(if !is_host {
             CursorStyle::PointingHand
         } else {
             CursorStyle::Arrow
         })
         .on_click(move |_, cx| {
-            if !is_host && !is_guest {
+            if !is_host {
                 cx.dispatch_global_action(JoinProject {
-                    project_id,
+                    contact: contact.clone(),
+                    project_index,
                     app_state: app_state.clone(),
                 });
             }
@@ -743,12 +768,12 @@ impl ContactsPanel {
                         let section = *section;
                         self.toggle_expanded(&ToggleExpanded(section), cx);
                     }
-                    ContactEntry::ContactProject(contact, project_ix) => {
-                        cx.dispatch_global_action(JoinProject {
-                            project_id: contact.projects[*project_ix].id,
+                    ContactEntry::ContactProject(contact, project_index) => cx
+                        .dispatch_global_action(JoinProject {
+                            contact: contact.clone(),
+                            project_index: *project_index,
                             app_state: self.app_state.clone(),
-                        })
-                    }
+                        }),
                     _ => {}
                 }
             }
@@ -947,7 +972,6 @@ mod tests {
                     projects: vec![proto::ProjectMetadata {
                         id: 101,
                         worktree_root_names: vec!["dir1".to_string()],
-                        is_shared: true,
                         guests: vec![2],
                     }],
                 },
@@ -958,7 +982,6 @@ mod tests {
                     projects: vec![proto::ProjectMetadata {
                         id: 102,
                         worktree_root_names: vec!["dir2".to_string()],
-                        is_shared: true,
                         guests: vec![2],
                     }],
                 },

crates/contacts_panel/src/join_project_notification.rs 🔗

@@ -0,0 +1,80 @@
+use client::User;
+use gpui::{
+    actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
+};
+use project::Project;
+use std::sync::Arc;
+use workspace::Notification;
+
+use crate::notifications::render_user_notification;
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(JoinProjectNotification::decline);
+    cx.add_action(JoinProjectNotification::accept);
+}
+
+pub enum Event {
+    Dismiss,
+}
+
+actions!(contacts_panel, [Accept, Decline]);
+
+pub struct JoinProjectNotification {
+    project: ModelHandle<Project>,
+    user: Arc<User>,
+}
+
+impl JoinProjectNotification {
+    pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> Self {
+        cx.subscribe(&project, |this, _, event, cx| {
+            if let project::Event::ContactCancelledJoinRequest(user) = event {
+                if *user == this.user {
+                    cx.emit(Event::Dismiss);
+                }
+            }
+        })
+        .detach();
+        Self { project, user }
+    }
+
+    fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
+        self.project.update(cx, |project, cx| {
+            project.respond_to_join_request(self.user.id, false, cx)
+        });
+        cx.emit(Event::Dismiss)
+    }
+
+    fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
+        self.project.update(cx, |project, cx| {
+            project.respond_to_join_request(self.user.id, true, cx)
+        });
+        cx.emit(Event::Dismiss)
+    }
+}
+
+impl Entity for JoinProjectNotification {
+    type Event = Event;
+}
+
+impl View for JoinProjectNotification {
+    fn ui_name() -> &'static str {
+        "JoinProjectNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        render_user_notification(
+            self.user.clone(),
+            "wants to join your project",
+            None,
+            Decline,
+            vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
+            cx,
+        )
+    }
+}
+
+impl Notification for JoinProjectNotification {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+        matches!(event, Event::Dismiss)
+    }
+}

crates/contacts_panel/src/notifications.rs 🔗

@@ -0,0 +1,113 @@
+use crate::render_icon_button;
+use client::User;
+use gpui::{
+    elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
+    platform::CursorStyle,
+    Action, Element, ElementBox, RenderContext, View,
+};
+use settings::Settings;
+use std::sync::Arc;
+
+enum Dismiss {}
+enum Button {}
+
+pub fn render_user_notification<V: View, A: Action + Clone>(
+    user: Arc<User>,
+    title: &str,
+    body: Option<&str>,
+    dismiss_action: A,
+    buttons: Vec<(&'static str, Box<dyn Action>)>,
+    cx: &mut RenderContext<V>,
+) -> ElementBox {
+    let theme = cx.global::<Settings>().theme.clone();
+    let theme = &theme.contact_notification;
+
+    Flex::column()
+        .with_child(
+            Flex::row()
+                .with_children(user.avatar.clone().map(|avatar| {
+                    Image::new(avatar)
+                        .with_style(theme.header_avatar)
+                        .aligned()
+                        .constrained()
+                        .with_height(
+                            cx.font_cache()
+                                .line_height(theme.header_message.text.font_size),
+                        )
+                        .aligned()
+                        .top()
+                        .boxed()
+                }))
+                .with_child(
+                    Text::new(
+                        format!("{} {}", user.github_login, title),
+                        theme.header_message.text.clone(),
+                    )
+                    .contained()
+                    .with_style(theme.header_message.container)
+                    .aligned()
+                    .top()
+                    .left()
+                    .flex(1., true)
+                    .boxed(),
+                )
+                .with_child(
+                    MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |state, _| {
+                        render_icon_button(
+                            theme.dismiss_button.style_for(state, false),
+                            "icons/decline.svg",
+                        )
+                        .boxed()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .with_padding(Padding::uniform(5.))
+                    .on_click(move |_, cx| cx.dispatch_any_action(dismiss_action.boxed_clone()))
+                    .aligned()
+                    .constrained()
+                    .with_height(
+                        cx.font_cache()
+                            .line_height(theme.header_message.text.font_size),
+                    )
+                    .aligned()
+                    .top()
+                    .flex_float()
+                    .boxed(),
+                )
+                .named("contact notification header"),
+        )
+        .with_children(body.map(|body| {
+            Label::new(
+                body.to_string(),
+                theme.body_message.text.clone(),
+            )
+            .contained()
+            .with_style(theme.body_message.container)
+            .boxed()
+        }))
+        .with_children(if buttons.is_empty() {
+            None
+        } else {
+            Some(
+                Flex::row()
+                    .with_children(buttons.into_iter().enumerate().map(
+                        |(ix, (message, action))| {
+                            MouseEventHandler::new::<Button, _, _>(ix, cx, |state, _| {
+                                let button = theme.button.style_for(state, false);
+                                Label::new(message.to_string(), button.text.clone())
+                                    .contained()
+                                    .with_style(button.container)
+                                    .boxed()
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(move |_, cx| cx.dispatch_any_action(action.boxed_clone()))
+                            .boxed()
+                        },
+                    ))
+                    .aligned()
+                    .right()
+                    .boxed(),
+            )
+        })
+        .contained()
+        .boxed()
+}

crates/gpui/src/app.rs 🔗

@@ -1611,6 +1611,20 @@ impl MutableAppContext {
         })
     }
 
+    pub fn replace_root_view<T, F>(&mut self, window_id: usize, build_root_view: F) -> ViewHandle<T>
+    where
+        T: View,
+        F: FnOnce(&mut ViewContext<T>) -> T,
+    {
+        self.update(|this| {
+            let root_view = this.add_view(window_id, build_root_view);
+            let window = this.cx.windows.get_mut(&window_id).unwrap();
+            window.root_view = root_view.clone().into();
+            window.focused_view_id = Some(root_view.id());
+            root_view
+        })
+    }
+
     pub fn remove_window(&mut self, window_id: usize) {
         self.cx.windows.remove(&window_id);
         self.presenters_and_platform_windows.remove(&window_id);
@@ -1628,20 +1642,22 @@ impl MutableAppContext {
 
         {
             let mut app = self.upgrade();
-            let presenter = presenter.clone();
+            let presenter = Rc::downgrade(&presenter);
             window.on_event(Box::new(move |event| {
                 app.update(|cx| {
-                    if let Event::KeyDown { keystroke, .. } = &event {
-                        if cx.dispatch_keystroke(
-                            window_id,
-                            presenter.borrow().dispatch_path(cx.as_ref()),
-                            keystroke,
-                        ) {
-                            return;
+                    if let Some(presenter) = presenter.upgrade() {
+                        if let Event::KeyDown { keystroke, .. } = &event {
+                            if cx.dispatch_keystroke(
+                                window_id,
+                                presenter.borrow().dispatch_path(cx.as_ref()),
+                                keystroke,
+                            ) {
+                                return;
+                            }
                         }
-                    }
 
-                    presenter.borrow_mut().dispatch_event(event, cx);
+                        presenter.borrow_mut().dispatch_event(event, cx);
+                    }
                 })
             }));
         }
@@ -3224,6 +3240,21 @@ impl<'a, T: View> ViewContext<'a, T> {
         self.app.add_option_view(self.window_id, build_view)
     }
 
+    pub fn replace_root_view<V, F>(&mut self, build_root_view: F) -> ViewHandle<V>
+    where
+        V: View,
+        F: FnOnce(&mut ViewContext<V>) -> V,
+    {
+        let window_id = self.window_id;
+        self.update(|this| {
+            let root_view = this.add_view(window_id, build_root_view);
+            let window = this.cx.windows.get_mut(&window_id).unwrap();
+            window.root_view = root_view.clone().into();
+            window.focused_view_id = Some(root_view.id());
+            root_view
+        })
+    }
+
     pub fn subscribe<E, H, F>(&mut self, handle: &H, mut callback: F) -> Subscription
     where
         E: Entity,

crates/gpui/src/presenter.rs 🔗

@@ -388,13 +388,17 @@ impl<'a> EventContext<'a> {
         }
     }
 
-    pub fn dispatch_action<A: Action>(&mut self, action: A) {
+    pub fn dispatch_any_action(&mut self, action: Box<dyn Action>) {
         self.dispatched_actions.push(DispatchDirective {
             path: self.view_stack.clone(),
-            action: Box::new(action),
+            action,
         });
     }
 
+    pub fn dispatch_action<A: Action>(&mut self, action: A) {
+        self.dispatch_any_action(Box::new(action));
+    }
+
     pub fn notify(&mut self) {
         self.notify_count += 1;
         if let Some(view_id) = self.view_stack.last() {

crates/project/Cargo.toml 🔗

@@ -45,6 +45,7 @@ serde_json = { version = "1.0.64", features = ["preserve_order"] }
 sha2 = "0.10"
 similar = "1.3"
 smol = "1.2.5"
+thiserror = "1.0.29"
 toml = "0.5"
 
 [dev-dependencies]

crates/project/src/project.rs 🔗

@@ -49,6 +49,7 @@ use std::{
     },
     time::Instant,
 };
+use thiserror::Error;
 use util::{post_inc, ResultExt, TryFutureExt as _};
 
 pub use fs::*;
@@ -90,6 +91,18 @@ pub struct Project {
     nonce: u128,
 }
 
+#[derive(Error, Debug)]
+pub enum JoinProjectError {
+    #[error("host declined join request")]
+    HostDeclined,
+    #[error("host closed the project")]
+    HostClosedProject,
+    #[error("host went offline")]
+    HostWentOffline,
+    #[error("{0}")]
+    Other(#[from] anyhow::Error),
+}
+
 enum OpenBuffer {
     Strong(ModelHandle<Buffer>),
     Weak(WeakModelHandle<Buffer>),
@@ -123,7 +136,7 @@ pub struct Collaborator {
     pub replica_id: ReplicaId,
 }
 
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
     ActiveEntryChanged(Option<ProjectEntryId>),
     WorktreeRemoved(WorktreeId),
@@ -133,6 +146,8 @@ pub enum Event {
     DiagnosticsUpdated(ProjectPath),
     RemoteIdChanged(Option<u64>),
     CollaboratorLeft(PeerId),
+    ContactRequestedJoin(Arc<User>),
+    ContactCancelledJoinRequest(Arc<User>),
 }
 
 #[derive(Serialize)]
@@ -248,15 +263,18 @@ impl ProjectEntryId {
 
 impl Project {
     pub fn init(client: &Arc<Client>) {
+        client.add_model_message_handler(Self::handle_request_join_project);
         client.add_model_message_handler(Self::handle_add_collaborator);
         client.add_model_message_handler(Self::handle_buffer_reloaded);
         client.add_model_message_handler(Self::handle_buffer_saved);
         client.add_model_message_handler(Self::handle_start_language_server);
         client.add_model_message_handler(Self::handle_update_language_server);
         client.add_model_message_handler(Self::handle_remove_collaborator);
+        client.add_model_message_handler(Self::handle_join_project_request_cancelled);
         client.add_model_message_handler(Self::handle_register_worktree);
         client.add_model_message_handler(Self::handle_unregister_worktree);
-        client.add_model_message_handler(Self::handle_unshare_project);
+        client.add_model_message_handler(Self::handle_unregister_project);
+        client.add_model_message_handler(Self::handle_project_unshared);
         client.add_model_message_handler(Self::handle_update_buffer_file);
         client.add_model_message_handler(Self::handle_update_buffer);
         client.add_model_message_handler(Self::handle_update_diagnostic_summary);
@@ -353,7 +371,7 @@ impl Project {
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         cx: &mut AsyncAppContext,
-    ) -> Result<ModelHandle<Self>> {
+    ) -> Result<ModelHandle<Self>, JoinProjectError> {
         client.authenticate_and_connect(true, &cx).await?;
 
         let response = client
@@ -362,6 +380,24 @@ impl Project {
             })
             .await?;
 
+        let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? {
+            proto::join_project_response::Variant::Accept(response) => response,
+            proto::join_project_response::Variant::Decline(decline) => {
+                match proto::join_project_response::decline::Reason::from_i32(decline.reason) {
+                    Some(proto::join_project_response::decline::Reason::Declined) => {
+                        Err(JoinProjectError::HostDeclined)?
+                    }
+                    Some(proto::join_project_response::decline::Reason::Closed) => {
+                        Err(JoinProjectError::HostClosedProject)?
+                    }
+                    Some(proto::join_project_response::decline::Reason::WentOffline) => {
+                        Err(JoinProjectError::HostWentOffline)?
+                    }
+                    None => Err(anyhow!("missing decline reason"))?,
+                }
+            }
+        };
+
         let replica_id = response.replica_id as ReplicaId;
 
         let mut worktrees = Vec::new();
@@ -400,7 +436,7 @@ impl Project {
                             // Even if we're initially connected, any future change of the status means we momentarily disconnected.
                             if !is_connected || status.next().await.is_some() {
                                 if let Some(this) = this.upgrade(&cx) {
-                                    this.update(&mut cx, |this, cx| this.project_unshared(cx))
+                                    this.update(&mut cx, |this, cx| this.removed_from_project(cx))
                                 }
                             }
                             Ok(())
@@ -550,7 +586,7 @@ impl Project {
     }
 
     fn unregister(&mut self, cx: &mut ModelContext<Self>) {
-        self.unshare(cx);
+        self.unshared(cx);
         for worktree in &self.worktrees {
             if let Some(worktree) = worktree.upgrade(cx) {
                 worktree.update(cx, |worktree, _| {
@@ -810,64 +846,59 @@ impl Project {
         }
     }
 
-    pub fn can_share(&self, cx: &AppContext) -> bool {
-        self.is_local() && self.visible_worktrees(cx).next().is_some()
-    }
+    fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        let project_id;
+        if let ProjectClientState::Local {
+            remote_id_rx,
+            is_shared,
+            ..
+        } = &mut self.client_state
+        {
+            if *is_shared {
+                return Task::ready(Ok(()));
+            }
+            *is_shared = true;
+            if let Some(id) = *remote_id_rx.borrow() {
+                project_id = id;
+            } else {
+                return Task::ready(Err(anyhow!("project hasn't been registered")));
+            }
+        } else {
+            return Task::ready(Err(anyhow!("can't share a remote project")));
+        };
 
-    pub fn share(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        let rpc = self.client.clone();
-        cx.spawn(|this, mut cx| async move {
-            let project_id = this.update(&mut cx, |this, cx| {
-                if let ProjectClientState::Local {
-                    is_shared,
-                    remote_id_rx,
-                    ..
-                } = &mut this.client_state
-                {
-                    *is_shared = true;
-
-                    for open_buffer in this.opened_buffers.values_mut() {
-                        match open_buffer {
-                            OpenBuffer::Strong(_) => {}
-                            OpenBuffer::Weak(buffer) => {
-                                if let Some(buffer) = buffer.upgrade(cx) {
-                                    *open_buffer = OpenBuffer::Strong(buffer);
-                                }
-                            }
-                            OpenBuffer::Loading(_) => unreachable!(),
-                        }
+        for open_buffer in self.opened_buffers.values_mut() {
+            match open_buffer {
+                OpenBuffer::Strong(_) => {}
+                OpenBuffer::Weak(buffer) => {
+                    if let Some(buffer) = buffer.upgrade(cx) {
+                        *open_buffer = OpenBuffer::Strong(buffer);
                     }
+                }
+                OpenBuffer::Loading(_) => unreachable!(),
+            }
+        }
 
-                    for worktree_handle in this.worktrees.iter_mut() {
-                        match worktree_handle {
-                            WorktreeHandle::Strong(_) => {}
-                            WorktreeHandle::Weak(worktree) => {
-                                if let Some(worktree) = worktree.upgrade(cx) {
-                                    *worktree_handle = WorktreeHandle::Strong(worktree);
-                                }
-                            }
-                        }
+        for worktree_handle in self.worktrees.iter_mut() {
+            match worktree_handle {
+                WorktreeHandle::Strong(_) => {}
+                WorktreeHandle::Weak(worktree) => {
+                    if let Some(worktree) = worktree.upgrade(cx) {
+                        *worktree_handle = WorktreeHandle::Strong(worktree);
                     }
-
-                    remote_id_rx
-                        .borrow()
-                        .ok_or_else(|| anyhow!("no project id"))
-                } else {
-                    Err(anyhow!("can't share a remote project"))
                 }
-            })?;
-
-            rpc.request(proto::ShareProject { project_id }).await?;
+            }
+        }
 
-            let mut tasks = Vec::new();
-            this.update(&mut cx, |this, cx| {
-                for worktree in this.worktrees(cx).collect::<Vec<_>>() {
-                    worktree.update(cx, |worktree, cx| {
-                        let worktree = worktree.as_local_mut().unwrap();
-                        tasks.push(worktree.share(project_id, cx));
-                    });
-                }
+        let mut tasks = Vec::new();
+        for worktree in self.worktrees(cx).collect::<Vec<_>>() {
+            worktree.update(cx, |worktree, cx| {
+                let worktree = worktree.as_local_mut().unwrap();
+                tasks.push(worktree.share(project_id, cx));
             });
+        }
+
+        cx.spawn(|this, mut cx| async move {
             for task in tasks {
                 task.await?;
             }
@@ -876,15 +907,8 @@ impl Project {
         })
     }
 
-    pub fn unshare(&mut self, cx: &mut ModelContext<Self>) {
-        let rpc = self.client.clone();
-
-        if let ProjectClientState::Local {
-            is_shared,
-            remote_id_rx,
-            ..
-        } = &mut self.client_state
-        {
+    fn unshared(&mut self, cx: &mut ModelContext<Self>) {
+        if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
             if !*is_shared {
                 return;
             }
@@ -913,17 +937,35 @@ impl Project {
                 }
             }
 
-            if let Some(project_id) = *remote_id_rx.borrow() {
-                rpc.send(proto::UnshareProject { project_id }).log_err();
-            }
-
             cx.notify();
         } else {
             log::error!("attempted to unshare a remote project");
         }
     }
 
-    fn project_unshared(&mut self, cx: &mut ModelContext<Self>) {
+    pub fn respond_to_join_request(
+        &mut self,
+        requester_id: u64,
+        allow: bool,
+        cx: &mut ModelContext<Self>,
+    ) {
+        if let Some(project_id) = self.remote_id() {
+            let share = self.share(cx);
+            let client = self.client.clone();
+            cx.foreground()
+                .spawn(async move {
+                    share.await?;
+                    client.send(proto::RespondToJoinProjectRequest {
+                        requester_id,
+                        project_id,
+                        allow,
+                    })
+                })
+                .detach_and_log_err(cx);
+        }
+    }
+
+    fn removed_from_project(&mut self, cx: &mut ModelContext<Self>) {
         if let ProjectClientState::Remote {
             sharing_has_stopped,
             ..
@@ -3504,25 +3546,36 @@ impl Project {
                         });
                         let worktree = worktree?;
 
-                        let (remote_project_id, is_shared) =
-                            project.update(&mut cx, |project, cx| {
-                                project.add_worktree(&worktree, cx);
-                                (project.remote_id(), project.is_shared())
-                            });
+                        let remote_project_id = project.update(&mut cx, |project, cx| {
+                            project.add_worktree(&worktree, cx);
+                            project.remote_id()
+                        });
 
                         if let Some(project_id) = remote_project_id {
-                            if is_shared {
-                                worktree
-                                    .update(&mut cx, |worktree, cx| {
-                                        worktree.as_local_mut().unwrap().share(project_id, cx)
-                                    })
-                                    .await?;
-                            } else {
-                                worktree
-                                    .update(&mut cx, |worktree, cx| {
-                                        worktree.as_local_mut().unwrap().register(project_id, cx)
-                                    })
-                                    .await?;
+                            // Because sharing is async, we may have *unshared* the project by the time it completes,
+                            // in which case we need to register the worktree instead.
+                            loop {
+                                if project.read_with(&cx, |project, _| project.is_shared()) {
+                                    if worktree
+                                        .update(&mut cx, |worktree, cx| {
+                                            worktree.as_local_mut().unwrap().share(project_id, cx)
+                                        })
+                                        .await
+                                        .is_ok()
+                                    {
+                                        break;
+                                    }
+                                } else {
+                                    worktree
+                                        .update(&mut cx, |worktree, cx| {
+                                            worktree
+                                                .as_local_mut()
+                                                .unwrap()
+                                                .register(project_id, cx)
+                                        })
+                                        .await?;
+                                    break;
+                                }
                             }
                         }
 
@@ -3745,13 +3798,46 @@ impl Project {
 
     // RPC message handlers
 
-    async fn handle_unshare_project(
+    async fn handle_request_join_project(
         this: ModelHandle<Self>,
-        _: TypedEnvelope<proto::UnshareProject>,
+        message: TypedEnvelope<proto::RequestJoinProject>,
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        this.update(&mut cx, |this, cx| this.project_unshared(cx));
+        let user_id = message.payload.requester_id;
+        if this.read_with(&cx, |project, _| {
+            project.collaborators.values().any(|c| c.user.id == user_id)
+        }) {
+            this.update(&mut cx, |this, cx| {
+                this.respond_to_join_request(user_id, true, cx)
+            });
+        } else {
+            let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+            let user = user_store
+                .update(&mut cx, |store, cx| store.fetch_user(user_id, cx))
+                .await?;
+            this.update(&mut cx, |_, cx| cx.emit(Event::ContactRequestedJoin(user)));
+        }
+        Ok(())
+    }
+
+    async fn handle_unregister_project(
+        this: ModelHandle<Self>,
+        _: TypedEnvelope<proto::UnregisterProject>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| this.removed_from_project(cx));
+        Ok(())
+    }
+
+    async fn handle_project_unshared(
+        this: ModelHandle<Self>,
+        _: TypedEnvelope<proto::ProjectUnshared>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| this.unshared(cx));
         Ok(())
     }
 
@@ -3796,12 +3882,34 @@ impl Project {
                     buffer.update(cx, |buffer, cx| buffer.remove_peer(replica_id, cx));
                 }
             }
+
             cx.emit(Event::CollaboratorLeft(peer_id));
             cx.notify();
             Ok(())
         })
     }
 
+    async fn handle_join_project_request_cancelled(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::JoinProjectRequestCancelled>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        let user = this
+            .update(&mut cx, |this, cx| {
+                this.user_store.update(cx, |user_store, cx| {
+                    user_store.fetch_user(envelope.payload.requester_id, cx)
+                })
+            })
+            .await?;
+
+        this.update(&mut cx, |_, cx| {
+            cx.emit(Event::ContactCancelledJoinRequest(user));
+        });
+
+        Ok(())
+    }
+
     async fn handle_register_worktree(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::RegisterWorktree>,
@@ -4052,6 +4160,7 @@ impl Project {
                 .into_iter()
                 .map(|op| language::proto::deserialize_operation(op))
                 .collect::<Result<Vec<_>, _>>()?;
+            let is_remote = this.is_remote();
             match this.opened_buffers.entry(buffer_id) {
                 hash_map::Entry::Occupied(mut e) => match e.get_mut() {
                     OpenBuffer::Strong(buffer) => {
@@ -4061,6 +4170,11 @@ impl Project {
                     OpenBuffer::Weak(_) => {}
                 },
                 hash_map::Entry::Vacant(e) => {
+                    assert!(
+                        is_remote,
+                        "received buffer update from {:?}",
+                        envelope.original_sender_id
+                    );
                     e.insert(OpenBuffer::Loading(ops));
                 }
             }

crates/project/src/worktree.rs 🔗

@@ -616,8 +616,10 @@ impl LocalWorktree {
             let text = fs.load(&abs_path).await?;
             // Eagerly populate the snapshot with an updated entry for the loaded file
             let entry = this
-                .update(&mut cx, |this, _| {
-                    this.as_local().unwrap().refresh_entry(path, abs_path, None)
+                .update(&mut cx, |this, cx| {
+                    this.as_local()
+                        .unwrap()
+                        .refresh_entry(path, abs_path, None, cx)
                 })
                 .await?;
             this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
@@ -753,11 +755,12 @@ impl LocalWorktree {
         Some(cx.spawn(|this, mut cx| async move {
             rename.await?;
             let entry = this
-                .update(&mut cx, |this, _| {
+                .update(&mut cx, |this, cx| {
                     this.as_local_mut().unwrap().refresh_entry(
                         new_path.clone(),
                         abs_new_path,
                         Some(old_path),
+                        cx,
                     )
                 })
                 .await?;
@@ -793,10 +796,10 @@ impl LocalWorktree {
         cx.spawn(|this, mut cx| async move {
             write.await?;
             let entry = this
-                .update(&mut cx, |this, _| {
+                .update(&mut cx, |this, cx| {
                     this.as_local_mut()
                         .unwrap()
-                        .refresh_entry(path, abs_path, None)
+                        .refresh_entry(path, abs_path, None, cx)
                 })
                 .await?;
             this.update(&mut cx, |this, cx| {
@@ -813,18 +816,17 @@ impl LocalWorktree {
         path: Arc<Path>,
         abs_path: PathBuf,
         old_path: Option<Arc<Path>>,
-    ) -> impl Future<Output = Result<Entry>> {
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        let fs = self.fs.clone();
         let root_char_bag;
         let next_entry_id;
-        let fs = self.fs.clone();
-        let shared_snapshots_tx = self.share.as_ref().map(|share| share.snapshots_tx.clone());
-        let snapshot = self.background_snapshot.clone();
         {
-            let snapshot = snapshot.lock();
+            let snapshot = self.background_snapshot.lock();
             root_char_bag = snapshot.root_char_bag;
             next_entry_id = snapshot.next_entry_id.clone();
         }
-        async move {
+        cx.spawn_weak(|this, mut cx| async move {
             let entry = Entry::new(
                 path,
                 &fs.metadata(&abs_path)
@@ -833,16 +835,29 @@ impl LocalWorktree {
                 &next_entry_id,
                 root_char_bag,
             );
-            let mut snapshot = snapshot.lock();
-            if let Some(old_path) = old_path {
-                snapshot.remove_path(&old_path);
-            }
-            let entry = snapshot.insert_entry(entry, fs.as_ref());
-            if let Some(tx) = shared_snapshots_tx {
-                tx.send(snapshot.clone()).await.ok();
+
+            let this = this
+                .upgrade(&cx)
+                .ok_or_else(|| anyhow!("worktree was dropped"))?;
+            let (entry, snapshot, snapshots_tx) = this.read_with(&cx, |this, _| {
+                let this = this.as_local().unwrap();
+                let mut snapshot = this.background_snapshot.lock();
+                if let Some(old_path) = old_path {
+                    snapshot.remove_path(&old_path);
+                }
+                let entry = snapshot.insert_entry(entry, fs.as_ref());
+                snapshot.scan_id += 1;
+                let snapshots_tx = this.share.as_ref().map(|s| s.snapshots_tx.clone());
+                (entry, snapshot.clone(), snapshots_tx)
+            });
+            this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
+
+            if let Some(snapshots_tx) = snapshots_tx {
+                snapshots_tx.send(snapshot).await.ok();
             }
+
             Ok(entry)
-        }
+        })
     }
 
     pub fn register(
@@ -2171,8 +2186,7 @@ impl BackgroundScanner {
         let root_abs_path;
         let next_entry_id;
         {
-            let mut snapshot = self.snapshot.lock();
-            snapshot.scan_id += 1;
+            let snapshot = self.snapshot.lock();
             root_char_bag = snapshot.root_char_bag;
             root_abs_path = snapshot.abs_path.clone();
             next_entry_id = snapshot.next_entry_id.clone();
@@ -2197,6 +2211,7 @@ impl BackgroundScanner {
         let (scan_queue_tx, scan_queue_rx) = channel::unbounded();
         {
             let mut snapshot = self.snapshot.lock();
+            snapshot.scan_id += 1;
             for event in &events {
                 if let Ok(path) = event.path.strip_prefix(&root_abs_path) {
                     snapshot.remove_path(&path);

crates/rpc/proto/zed.proto 🔗

@@ -14,89 +14,91 @@ message Envelope {
         RegisterProject register_project = 8;
         RegisterProjectResponse register_project_response = 9;
         UnregisterProject unregister_project = 10;
-        ShareProject share_project = 11;
-        UnshareProject unshare_project = 12;
-        JoinProject join_project = 13;
-        JoinProjectResponse join_project_response = 14;
-        LeaveProject leave_project = 15;
-        AddProjectCollaborator add_project_collaborator = 16;
-        RemoveProjectCollaborator remove_project_collaborator = 17;
-
-        GetDefinition get_definition = 18;
-        GetDefinitionResponse get_definition_response = 19;
-        GetReferences get_references = 20;
-        GetReferencesResponse get_references_response = 21;
-        GetDocumentHighlights get_document_highlights = 22;
-        GetDocumentHighlightsResponse get_document_highlights_response = 23;
-        GetProjectSymbols get_project_symbols = 24;
-        GetProjectSymbolsResponse get_project_symbols_response = 25;
-        OpenBufferForSymbol open_buffer_for_symbol = 26;
-        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 27;
-
-        RegisterWorktree register_worktree = 28;
-        UnregisterWorktree unregister_worktree = 29;
-        UpdateWorktree update_worktree = 31;
-
-        CreateProjectEntry create_project_entry = 32;
-        RenameProjectEntry rename_project_entry = 33;
-        DeleteProjectEntry delete_project_entry = 34;
-        ProjectEntryResponse project_entry_response = 35;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 36;
-        StartLanguageServer start_language_server = 37;
-        UpdateLanguageServer update_language_server = 38;
-
-        OpenBufferById open_buffer_by_id = 39;
-        OpenBufferByPath open_buffer_by_path = 40;
-        OpenBufferResponse open_buffer_response = 41;
-        UpdateBuffer update_buffer = 42;
-        UpdateBufferFile update_buffer_file = 43;
-        SaveBuffer save_buffer = 44;
-        BufferSaved buffer_saved = 45;
-        BufferReloaded buffer_reloaded = 46;
-        ReloadBuffers reload_buffers = 47;
-        ReloadBuffersResponse reload_buffers_response = 48;
-        FormatBuffers format_buffers = 49;
-        FormatBuffersResponse format_buffers_response = 50;
-        GetCompletions get_completions = 51;
-        GetCompletionsResponse get_completions_response = 52;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 53;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 54;
-        GetCodeActions get_code_actions = 55;
-        GetCodeActionsResponse get_code_actions_response = 56;
-        ApplyCodeAction apply_code_action = 57;
-        ApplyCodeActionResponse apply_code_action_response = 58;
-        PrepareRename prepare_rename = 59;
-        PrepareRenameResponse prepare_rename_response = 60;
-        PerformRename perform_rename = 61;
-        PerformRenameResponse perform_rename_response = 62;
-        SearchProject search_project = 63;
-        SearchProjectResponse search_project_response = 64;
-
-        GetChannels get_channels = 65;
-        GetChannelsResponse get_channels_response = 66;
-        JoinChannel join_channel = 67;
-        JoinChannelResponse join_channel_response = 68;
-        LeaveChannel leave_channel = 69;
-        SendChannelMessage send_channel_message = 70;
-        SendChannelMessageResponse send_channel_message_response = 71;
-        ChannelMessageSent channel_message_sent = 72;
-        GetChannelMessages get_channel_messages = 73;
-        GetChannelMessagesResponse get_channel_messages_response = 74;
-
-        UpdateContacts update_contacts = 75;
-
-        GetUsers get_users = 76;
-        FuzzySearchUsers fuzzy_search_users = 77;
-        UsersResponse users_response = 78;
-        RequestContact request_contact = 79;
-        RespondToContactRequest respond_to_contact_request = 80;
-        RemoveContact remove_contact = 81;
-
-        Follow follow = 82;
-        FollowResponse follow_response = 83;
-        UpdateFollowers update_followers = 84;
-        Unfollow unfollow = 85;
+        RequestJoinProject request_join_project = 11;
+        RespondToJoinProjectRequest respond_to_join_project_request = 12;
+        JoinProjectRequestCancelled join_project_request_cancelled = 13;
+        JoinProject join_project = 14;
+        JoinProjectResponse join_project_response = 15;
+        LeaveProject leave_project = 16;
+        AddProjectCollaborator add_project_collaborator = 17;
+        RemoveProjectCollaborator remove_project_collaborator = 18;
+        ProjectUnshared project_unshared = 19;
+
+        GetDefinition get_definition = 20;
+        GetDefinitionResponse get_definition_response = 21;
+        GetReferences get_references = 22;
+        GetReferencesResponse get_references_response = 23;
+        GetDocumentHighlights get_document_highlights = 24;
+        GetDocumentHighlightsResponse get_document_highlights_response = 25;
+        GetProjectSymbols get_project_symbols = 26;
+        GetProjectSymbolsResponse get_project_symbols_response = 27;
+        OpenBufferForSymbol open_buffer_for_symbol = 28;
+        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 29;
+
+        RegisterWorktree register_worktree = 30;
+        UnregisterWorktree unregister_worktree = 31;
+        UpdateWorktree update_worktree = 32;
+
+        CreateProjectEntry create_project_entry = 33;
+        RenameProjectEntry rename_project_entry = 34;
+        DeleteProjectEntry delete_project_entry = 35;
+        ProjectEntryResponse project_entry_response = 36;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 37;
+        StartLanguageServer start_language_server = 38;
+        UpdateLanguageServer update_language_server = 39;
+
+        OpenBufferById open_buffer_by_id = 40;
+        OpenBufferByPath open_buffer_by_path = 41;
+        OpenBufferResponse open_buffer_response = 42;
+        UpdateBuffer update_buffer = 43;
+        UpdateBufferFile update_buffer_file = 44;
+        SaveBuffer save_buffer = 45;
+        BufferSaved buffer_saved = 46;
+        BufferReloaded buffer_reloaded = 47;
+        ReloadBuffers reload_buffers = 48;
+        ReloadBuffersResponse reload_buffers_response = 49;
+        FormatBuffers format_buffers = 50;
+        FormatBuffersResponse format_buffers_response = 51;
+        GetCompletions get_completions = 52;
+        GetCompletionsResponse get_completions_response = 53;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 54;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 55;
+        GetCodeActions get_code_actions = 56;
+        GetCodeActionsResponse get_code_actions_response = 57;
+        ApplyCodeAction apply_code_action = 58;
+        ApplyCodeActionResponse apply_code_action_response = 59;
+        PrepareRename prepare_rename = 60;
+        PrepareRenameResponse prepare_rename_response = 61;
+        PerformRename perform_rename = 62;
+        PerformRenameResponse perform_rename_response = 63;
+        SearchProject search_project = 64;
+        SearchProjectResponse search_project_response = 65;
+
+        GetChannels get_channels = 66;
+        GetChannelsResponse get_channels_response = 67;
+        JoinChannel join_channel = 68;
+        JoinChannelResponse join_channel_response = 69;
+        LeaveChannel leave_channel = 70;
+        SendChannelMessage send_channel_message = 71;
+        SendChannelMessageResponse send_channel_message_response = 72;
+        ChannelMessageSent channel_message_sent = 73;
+        GetChannelMessages get_channel_messages = 74;
+        GetChannelMessagesResponse get_channel_messages_response = 75;
+
+        UpdateContacts update_contacts = 76;
+
+        GetUsers get_users = 77;
+        FuzzySearchUsers fuzzy_search_users = 78;
+        UsersResponse users_response = 79;
+        RequestContact request_contact = 80;
+        RespondToContactRequest respond_to_contact_request = 81;
+        RemoveContact remove_contact = 82;
+
+        Follow follow = 83;
+        FollowResponse follow_response = 84;
+        UpdateFollowers update_followers = 85;
+        Unfollow unfollow = 86;
     }
 }
 
@@ -124,12 +126,20 @@ message UnregisterProject {
     uint64 project_id = 1;
 }
 
-message ShareProject {
-    uint64 project_id = 1;
+message RequestJoinProject {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
 }
 
-message UnshareProject {
-    uint64 project_id = 1;
+message RespondToJoinProjectRequest {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
+    bool allow = 3;
+}
+
+message JoinProjectRequestCancelled {
+    uint64 requester_id = 1;
+    uint64 project_id = 2;
 }
 
 message JoinProject {
@@ -137,10 +147,27 @@ message JoinProject {
 }
 
 message JoinProjectResponse {
-    uint32 replica_id = 1;
-    repeated Worktree worktrees = 2;
-    repeated Collaborator collaborators = 3;
-    repeated LanguageServer language_servers = 4;
+    oneof variant {
+        Accept accept = 1;
+        Decline decline = 2;
+    }
+
+    message Accept {
+        uint32 replica_id = 1;
+        repeated Worktree worktrees = 2;
+        repeated Collaborator collaborators = 3;
+        repeated LanguageServer language_servers = 4;        
+    }
+    
+    message Decline {
+        Reason reason = 1;
+
+        enum Reason {
+            Declined = 0;
+            Closed = 1;
+            WentOffline = 2;
+        }
+    }
 }
 
 message LeaveProject {
@@ -201,6 +228,10 @@ message RemoveProjectCollaborator {
     uint32 peer_id = 2;
 }
 
+message ProjectUnshared {
+    uint64 project_id = 1;
+}
+
 message GetDefinition {
      uint64 project_id = 1;
      uint64 buffer_id = 2;
@@ -882,7 +913,6 @@ message Contact {
 
 message ProjectMetadata {
     uint64 id = 1;
-    bool is_shared = 2;
     repeated string worktree_root_names = 3;
     repeated uint64 guests = 4;
 }

crates/rpc/src/proto.rs 🔗

@@ -114,6 +114,7 @@ messages!(
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
+    (JoinProjectRequestCancelled, Foreground),
     (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
     (OpenBufferById, Background),
@@ -128,6 +129,7 @@ messages!(
     (ProjectEntryResponse, Foreground),
     (RegisterProjectResponse, Foreground),
     (Ping, Foreground),
+    (ProjectUnshared, Foreground),
     (RegisterProject, Foreground),
     (RegisterWorktree, Foreground),
     (ReloadBuffers, Foreground),
@@ -135,19 +137,19 @@ messages!(
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
     (RequestContact, Foreground),
+    (RequestJoinProject, Foreground),
     (RespondToContactRequest, Foreground),
+    (RespondToJoinProjectRequest, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
     (SendChannelMessage, Foreground),
     (SendChannelMessageResponse, Foreground),
-    (ShareProject, Foreground),
     (StartLanguageServer, Foreground),
     (Test, Foreground),
     (Unfollow, Foreground),
     (UnregisterProject, Foreground),
     (UnregisterWorktree, Foreground),
-    (UnshareProject, Foreground),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
@@ -195,7 +197,6 @@ request_messages!(
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
-    (ShareProject, Ack),
     (Test, Test),
     (UpdateBuffer, Ack),
     (UpdateWorktree, Ack),
@@ -220,20 +221,23 @@ entity_messages!(
     GetReferences,
     GetProjectSymbols,
     JoinProject,
+    JoinProjectRequestCancelled,
     LeaveProject,
     OpenBufferById,
     OpenBufferByPath,
     OpenBufferForSymbol,
     PerformRename,
     PrepareRename,
+    ProjectUnshared,
     ReloadBuffers,
     RemoveProjectCollaborator,
+    RequestJoinProject,
     SaveBuffer,
     SearchProject,
     StartLanguageServer,
     Unfollow,
+    UnregisterProject,
     UnregisterWorktree,
-    UnshareProject,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 17;
+pub const PROTOCOL_VERSION: u32 = 18;

crates/theme/src/theme.rs 🔗

@@ -48,6 +48,8 @@ pub struct Workspace {
     pub modal: ContainerStyle,
     pub notification: ContainerStyle,
     pub notifications: Notifications,
+    pub joining_project_avatar: ImageStyle,
+    pub joining_project_message: ContainedText,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -251,8 +253,7 @@ pub struct ContactsPanel {
     pub add_contact_button: IconButton,
     pub header_row: Interactive<ContainedText>,
     pub contact_row: Interactive<ContainerStyle>,
-    pub shared_project_row: Interactive<ProjectRow>,
-    pub unshared_project_row: Interactive<ProjectRow>,
+    pub project_row: Interactive<ProjectRow>,
     pub row_height: f32,
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainedText,

crates/workspace/src/waiting_room.rs 🔗

@@ -0,0 +1,190 @@
+use crate::{
+    sidebar::{Side, ToggleSidebarItem},
+    AppState, ToggleFollow,
+};
+use anyhow::Result;
+use client::{proto, Client, Contact};
+use gpui::{
+    elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View,
+    ViewContext,
+};
+use project::Project;
+use settings::Settings;
+use std::sync::Arc;
+use util::ResultExt;
+
+pub struct WaitingRoom {
+    project_id: u64,
+    avatar: Option<Arc<ImageData>>,
+    message: String,
+    waiting: bool,
+    client: Arc<Client>,
+    _join_task: Task<Result<()>>,
+}
+
+impl Entity for WaitingRoom {
+    type Event = ();
+
+    fn release(&mut self, _: &mut MutableAppContext) {
+        if self.waiting {
+            self.client
+                .send(proto::LeaveProject {
+                    project_id: self.project_id,
+                })
+                .log_err();
+        }
+    }
+}
+
+impl View for WaitingRoom {
+    fn ui_name() -> &'static str {
+        "WaitingRoom"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.workspace;
+
+        Flex::column()
+            .with_children(self.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.joining_project_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .with_child(
+                Text::new(
+                    self.message.clone(),
+                    theme.joining_project_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.joining_project_message.container)
+                .aligned()
+                .boxed(),
+            )
+            .aligned()
+            .contained()
+            .with_background_color(theme.background)
+            .boxed()
+    }
+}
+
+impl WaitingRoom {
+    pub fn new(
+        contact: Arc<Contact>,
+        project_index: usize,
+        app_state: Arc<AppState>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let project_id = contact.projects[project_index].id;
+        let client = app_state.client.clone();
+        let _join_task = cx.spawn_weak({
+            let contact = contact.clone();
+            |this, mut cx| async move {
+                let project = Project::remote(
+                    project_id,
+                    app_state.client.clone(),
+                    app_state.user_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
+                    &mut cx,
+                )
+                .await;
+
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.waiting = false;
+                        match project {
+                            Ok(project) => {
+                                cx.replace_root_view(|cx| {
+                                    let mut workspace = (app_state.build_workspace)(
+                                        project.clone(),
+                                        &app_state,
+                                        cx,
+                                    );
+                                    workspace.toggle_sidebar_item(
+                                        &ToggleSidebarItem {
+                                            side: Side::Left,
+                                            item_index: 0,
+                                        },
+                                        cx,
+                                    );
+                                    if let Some((host_peer_id, _)) = project
+                                        .read(cx)
+                                        .collaborators()
+                                        .iter()
+                                        .find(|(_, collaborator)| collaborator.replica_id == 0)
+                                    {
+                                        if let Some(follow) = workspace
+                                            .toggle_follow(&ToggleFollow(*host_peer_id), cx)
+                                        {
+                                            follow.detach_and_log_err(cx);
+                                        }
+                                    }
+                                    workspace
+                                });
+                            }
+                            Err(error @ _) => {
+                                let login = &contact.user.github_login;
+                                let message = match error {
+                                    project::JoinProjectError::HostDeclined => {
+                                        format!("@{} declined your request.", login)
+                                    }
+                                    project::JoinProjectError::HostClosedProject => {
+                                        format!(
+                                            "@{} closed their copy of {}.",
+                                            login,
+                                            humanize_list(
+                                                &contact.projects[project_index]
+                                                    .worktree_root_names
+                                            )
+                                        )
+                                    }
+                                    project::JoinProjectError::HostWentOffline => {
+                                        format!("@{} went offline.", login)
+                                    }
+                                    project::JoinProjectError::Other(error) => {
+                                        log::error!("error joining project: {}", error);
+                                        "An error occurred.".to_string()
+                                    }
+                                };
+                                this.message = message;
+                                cx.notify();
+                            }
+                        }
+                    })
+                }
+
+                Ok(())
+            }
+        });
+
+        Self {
+            project_id,
+            avatar: contact.user.avatar.clone(),
+            message: format!(
+                "Asking to join @{}'s copy of {}...",
+                contact.user.github_login,
+                humanize_list(&contact.projects[project_index].worktree_root_names)
+            ),
+            waiting: true,
+            client,
+            _join_task,
+        }
+    }
+}
+
+fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
+    let mut list = String::new();
+    let mut items = items.into_iter().enumerate().peekable();
+    while let Some((ix, item)) = items.next() {
+        if ix > 0 {
+            list.push_str(", ");
+            if items.peek().is_none() {
+                list.push_str("and ");
+            }
+        }
+
+        list.push_str(item);
+    }
+    list
+}

crates/workspace/src/workspace.rs 🔗

@@ -5,10 +5,12 @@ pub mod pane_group;
 pub mod sidebar;
 mod status_bar;
 mod toolbar;
+mod waiting_room;
 
 use anyhow::{anyhow, Context, Result};
 use client::{
-    proto, Authenticate, ChannelList, Client, PeerId, Subscription, TypedEnvelope, User, UserStore,
+    proto, Authenticate, ChannelList, Client, Contact, PeerId, Subscription, TypedEnvelope, User,
+    UserStore,
 };
 use clock::ReplicaId;
 use collections::{hash_map, HashMap, HashSet};
@@ -49,6 +51,7 @@ use std::{
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
+use waiting_room::WaitingRoom;
 
 type ProjectItemBuilders = HashMap<
     TypeId,
@@ -72,7 +75,6 @@ type FollowableItemBuilders = HashMap<
 actions!(
     workspace,
     [
-        ToggleShare,
         Unfollow,
         Save,
         ActivatePreviousPane,
@@ -98,7 +100,8 @@ pub struct ToggleFollow(pub PeerId);
 
 #[derive(Clone)]
 pub struct JoinProject {
-    pub project_id: u64,
+    pub contact: Arc<Contact>,
+    pub project_index: usize,
     pub app_state: Arc<AppState>,
 }
 
@@ -118,10 +121,14 @@ pub fn init(client: &Arc<Client>, cx: &mut MutableAppContext) {
         open_new(&action.0, cx)
     });
     cx.add_global_action(move |action: &JoinProject, cx: &mut MutableAppContext| {
-        join_project(action.project_id, &action.app_state, cx).detach();
+        join_project(
+            action.contact.clone(),
+            action.project_index,
+            &action.app_state,
+            cx,
+        );
     });
 
-    cx.add_action(Workspace::toggle_share);
     cx.add_async_action(Workspace::toggle_follow);
     cx.add_async_action(Workspace::follow_next_collaborator);
     cx.add_action(
@@ -692,6 +699,7 @@ impl WorkspaceParams {
 
 pub enum Event {
     PaneAdded(ViewHandle<Pane>),
+    ContactRequestedJoin(u64),
 }
 
 pub struct Workspace {
@@ -1366,18 +1374,6 @@ impl Workspace {
         &self.active_pane
     }
 
-    fn toggle_share(&mut self, _: &ToggleShare, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            if project.is_local() {
-                if project.is_shared() {
-                    project.unshare(cx);
-                } else if project.can_share(cx) {
-                    project.share(cx).detach();
-                }
-            }
-        });
-    }
-
     fn project_remote_id_changed(&mut self, remote_id: Option<u64>, cx: &mut ViewContext<Self>) {
         if let Some(remote_id) = remote_id {
             self.remote_entity_subscription =
@@ -1580,7 +1576,6 @@ impl Workspace {
                                     cx,
                                 ))
                                 .with_children(self.render_connection_status(cx))
-                                .with_children(self.render_share_icon(theme, cx))
                                 .boxed(),
                         )
                         .right()
@@ -1701,39 +1696,6 @@ impl Workspace {
         }
     }
 
-    fn render_share_icon(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
-        if self.project().read(cx).is_local()
-            && self.client.user_id().is_some()
-            && self.project().read(cx).can_share(cx)
-        {
-            Some(
-                MouseEventHandler::new::<ToggleShare, _, _>(0, cx, |state, cx| {
-                    let style = &theme
-                        .workspace
-                        .titlebar
-                        .share_icon
-                        .style_for(state, self.project().read(cx).is_shared());
-                    Svg::new("icons/share.svg")
-                        .with_color(style.color)
-                        .constrained()
-                        .with_height(14.)
-                        .aligned()
-                        .contained()
-                        .with_style(style.container)
-                        .constrained()
-                        .with_width(24.)
-                        .aligned()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(|_, cx| cx.dispatch_action(ToggleShare))
-                .boxed(),
-            )
-        } else {
-            None
-        }
-    }
-
     fn render_disconnected_overlay(&self, cx: &AppContext) -> Option<ElementBox> {
         if self.project.read(cx).is_read_only() {
             let theme = &cx.global::<Settings>().theme;
@@ -2315,36 +2277,25 @@ pub fn open_paths(
 }
 
 pub fn join_project(
-    project_id: u64,
+    contact: Arc<Contact>,
+    project_index: usize,
     app_state: &Arc<AppState>,
     cx: &mut MutableAppContext,
-) -> Task<Result<ViewHandle<Workspace>>> {
+) {
+    let project_id = contact.projects[project_index].id;
+
     for window_id in cx.window_ids().collect::<Vec<_>>() {
         if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
             if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
-                return Task::ready(Ok(workspace));
+                cx.activate_window(window_id);
+                return;
             }
         }
     }
 
-    let app_state = app_state.clone();
-    cx.spawn(|mut cx| async move {
-        let project = Project::remote(
-            project_id,
-            app_state.client.clone(),
-            app_state.user_store.clone(),
-            app_state.languages.clone(),
-            app_state.fs.clone(),
-            &mut cx,
-        )
-        .await?;
-        Ok(cx.update(|cx| {
-            cx.add_window((app_state.build_window_options)(), |cx| {
-                (app_state.build_workspace)(project, &app_state, cx)
-            })
-            .1
-        }))
-    })
+    cx.add_window((app_state.build_window_options)(), |cx| {
+        WaitingRoom::new(contact, project_index, app_state.clone(), cx)
+    });
 }
 
 fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {

crates/zed/src/zed.rs 🔗

@@ -123,17 +123,18 @@ pub fn build_workspace(
     cx.subscribe(&cx.handle(), {
         let project = project.clone();
         move |_, _, event, cx| {
-            let workspace::Event::PaneAdded(pane) = event;
-            pane.update(cx, |pane, cx| {
-                pane.toolbar().update(cx, |toolbar, cx| {
-                    let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
-                    toolbar.add_item(breadcrumbs, cx);
-                    let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
-                    toolbar.add_item(buffer_search_bar, cx);
-                    let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
-                    toolbar.add_item(project_search_bar, cx);
-                })
-            });
+            if let workspace::Event::PaneAdded(pane) = event {
+                pane.update(cx, |pane, cx| {
+                    pane.toolbar().update(cx, |toolbar, cx| {
+                        let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
+                        toolbar.add_item(breadcrumbs, cx);
+                        let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
+                        toolbar.add_item(buffer_search_bar, cx);
+                        let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
+                        toolbar.add_item(project_search_bar, cx);
+                    })
+                });
+            }
         }
     })
     .detach();

styles/dist/dark.json 🔗

@@ -1,531 +0,0 @@
-{
-  "meta": {
-    "themeName": "cave-dark"
-  },
-  "text": {
-    "primary": {
-      "value": "#e2dfe7",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#8b8792",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#8b8792",
-      "type": "color"
-    },
-    "placeholder": {
-      "value": "#7e7887",
-      "type": "color"
-    },
-    "active": {
-      "value": "#efecf4",
-      "type": "color"
-    },
-    "feature": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be4678",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb",
-      "type": "color"
-    }
-  },
-  "icon": {
-    "primary": {
-      "value": "#e2dfe7",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#8b8792",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#8b8792",
-      "type": "color"
-    },
-    "placeholder": {
-      "value": "#7e7887",
-      "type": "color"
-    },
-    "active": {
-      "value": "#efecf4",
-      "type": "color"
-    },
-    "feature": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be4678",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb",
-      "type": "color"
-    }
-  },
-  "background": {
-    "100": {
-      "base": {
-        "value": "#26232a",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#5852603d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#5852605c",
-        "type": "color"
-      }
-    },
-    "300": {
-      "base": {
-        "value": "#26232a",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#5852603d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#5852605c",
-        "type": "color"
-      }
-    },
-    "500": {
-      "base": {
-        "value": "#19171c",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#26232a3d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#26232a5c",
-        "type": "color"
-      }
-    },
-    "on300": {
-      "base": {
-        "value": "#19171c",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#26232a3d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#26232a7a",
-        "type": "color"
-      }
-    },
-    "on500": {
-      "base": {
-        "value": "#26232a",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#5852603d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#5852607a",
-        "type": "color"
-      }
-    },
-    "ok": {
-      "base": {
-        "value": "#2a929226",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#2a929233",
-        "type": "color"
-      },
-      "active": {
-        "value": "#2a929240",
-        "type": "color"
-      }
-    },
-    "error": {
-      "base": {
-        "value": "#be467826",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#be467833",
-        "type": "color"
-      },
-      "active": {
-        "value": "#be467840",
-        "type": "color"
-      }
-    },
-    "warning": {
-      "base": {
-        "value": "#a06e3b26",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#a06e3b33",
-        "type": "color"
-      },
-      "active": {
-        "value": "#a06e3b40",
-        "type": "color"
-      }
-    },
-    "info": {
-      "base": {
-        "value": "#576ddb26",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#576ddb33",
-        "type": "color"
-      },
-      "active": {
-        "value": "#576ddb40",
-        "type": "color"
-      }
-    }
-  },
-  "border": {
-    "primary": {
-      "value": "#19171c",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#26232a",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#655f6d",
-      "type": "color"
-    },
-    "active": {
-      "value": "#655f6d",
-      "type": "color"
-    },
-    "onMedia": {
-      "value": "#19171c1a",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a929226",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be467826",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b26",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb26",
-      "type": "color"
-    }
-  },
-  "editor": {
-    "background": {
-      "value": "#19171c",
-      "type": "color"
-    },
-    "indent_guide": {
-      "value": "#655f6d",
-      "type": "color"
-    },
-    "indent_guide_active": {
-      "value": "#26232a",
-      "type": "color"
-    },
-    "line": {
-      "active": {
-        "value": "#efecf412",
-        "type": "color"
-      },
-      "highlighted": {
-        "value": "#efecf41f",
-        "type": "color"
-      },
-      "inserted": {
-        "value": "#2a929240",
-        "type": "color"
-      },
-      "deleted": {
-        "value": "#be467840",
-        "type": "color"
-      },
-      "modified": {
-        "value": "#576ddb40",
-        "type": "color"
-      }
-    },
-    "highlight": {
-      "selection": {
-        "value": "#576ddb3d",
-        "type": "color"
-      },
-      "occurrence": {
-        "value": "#efecf41f",
-        "type": "color"
-      },
-      "activeOccurrence": {
-        "value": "#efecf43d",
-        "type": "color"
-      },
-      "matchingBracket": {
-        "value": "#26232a5c",
-        "type": "color"
-      },
-      "match": {
-        "value": "#955ae77a",
-        "type": "color"
-      },
-      "activeMatch": {
-        "value": "#955ae7b8",
-        "type": "color"
-      },
-      "related": {
-        "value": "#26232a3d",
-        "type": "color"
-      }
-    },
-    "gutter": {
-      "primary": {
-        "value": "#7e7887",
-        "type": "color"
-      },
-      "active": {
-        "value": "#efecf4",
-        "type": "color"
-      }
-    }
-  },
-  "syntax": {
-    "primary": {
-      "value": "#efecf4",
-      "type": "color"
-    },
-    "comment": {
-      "value": "#8b8792",
-      "type": "color"
-    },
-    "keyword": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "function": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "type": {
-      "value": "#398bc6",
-      "type": "color"
-    },
-    "variant": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "property": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "enum": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "operator": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "string": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "number": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "boolean": {
-      "value": "#2a9292",
-      "type": "color"
-    }
-  },
-  "player": {
-    "1": {
-      "baseColor": {
-        "value": "#576ddb",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#576ddb",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#576ddb3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#576ddbcc",
-        "type": "color"
-      }
-    },
-    "2": {
-      "baseColor": {
-        "value": "#2a9292",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#2a9292",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#2a92923d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#2a9292cc",
-        "type": "color"
-      }
-    },
-    "3": {
-      "baseColor": {
-        "value": "#bf40bf",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#bf40bf",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#bf40bf3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#bf40bfcc",
-        "type": "color"
-      }
-    },
-    "4": {
-      "baseColor": {
-        "value": "#aa573c",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#aa573c",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#aa573c3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#aa573ccc",
-        "type": "color"
-      }
-    },
-    "5": {
-      "baseColor": {
-        "value": "#955ae7",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#955ae7",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#955ae73d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#955ae7cc",
-        "type": "color"
-      }
-    },
-    "6": {
-      "baseColor": {
-        "value": "#398bc6",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#398bc6",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#398bc63d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#398bc6cc",
-        "type": "color"
-      }
-    },
-    "7": {
-      "baseColor": {
-        "value": "#be4678",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#be4678",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#be46783d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#be4678cc",
-        "type": "color"
-      }
-    },
-    "8": {
-      "baseColor": {
-        "value": "#a06e3b",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#a06e3b",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#a06e3b3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#a06e3bcc",
-        "type": "color"
-      }
-    }
-  },
-  "shadowAlpha": {
-    "value": 0.24,
-    "type": "number"
-  }
-}

styles/dist/light.json 🔗

@@ -1,531 +0,0 @@
-{
-  "meta": {
-    "themeName": "cave-light"
-  },
-  "text": {
-    "primary": {
-      "value": "#26232a",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#585260",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#585260",
-      "type": "color"
-    },
-    "placeholder": {
-      "value": "#655f6d",
-      "type": "color"
-    },
-    "active": {
-      "value": "#19171c",
-      "type": "color"
-    },
-    "feature": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be4678",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb",
-      "type": "color"
-    }
-  },
-  "icon": {
-    "primary": {
-      "value": "#26232a",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#585260",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#585260",
-      "type": "color"
-    },
-    "placeholder": {
-      "value": "#655f6d",
-      "type": "color"
-    },
-    "active": {
-      "value": "#19171c",
-      "type": "color"
-    },
-    "feature": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be4678",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb",
-      "type": "color"
-    }
-  },
-  "background": {
-    "100": {
-      "base": {
-        "value": "#e2dfe7",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#8b87921f",
-        "type": "color"
-      },
-      "active": {
-        "value": "#8b87922e",
-        "type": "color"
-      }
-    },
-    "300": {
-      "base": {
-        "value": "#e2dfe7",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#8b87921f",
-        "type": "color"
-      },
-      "active": {
-        "value": "#8b87922e",
-        "type": "color"
-      }
-    },
-    "500": {
-      "base": {
-        "value": "#efecf4",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#e2dfe71f",
-        "type": "color"
-      },
-      "active": {
-        "value": "#e2dfe72e",
-        "type": "color"
-      }
-    },
-    "on300": {
-      "base": {
-        "value": "#efecf4",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#e2dfe71f",
-        "type": "color"
-      },
-      "active": {
-        "value": "#e2dfe73d",
-        "type": "color"
-      }
-    },
-    "on500": {
-      "base": {
-        "value": "#e2dfe7",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#8b87921f",
-        "type": "color"
-      },
-      "active": {
-        "value": "#8b87923d",
-        "type": "color"
-      }
-    },
-    "ok": {
-      "base": {
-        "value": "#2a929226",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#2a929233",
-        "type": "color"
-      },
-      "active": {
-        "value": "#2a929240",
-        "type": "color"
-      }
-    },
-    "error": {
-      "base": {
-        "value": "#be467826",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#be467833",
-        "type": "color"
-      },
-      "active": {
-        "value": "#be467840",
-        "type": "color"
-      }
-    },
-    "warning": {
-      "base": {
-        "value": "#a06e3b26",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#a06e3b33",
-        "type": "color"
-      },
-      "active": {
-        "value": "#a06e3b40",
-        "type": "color"
-      }
-    },
-    "info": {
-      "base": {
-        "value": "#576ddb26",
-        "type": "color"
-      },
-      "hovered": {
-        "value": "#576ddb33",
-        "type": "color"
-      },
-      "active": {
-        "value": "#576ddb40",
-        "type": "color"
-      }
-    }
-  },
-  "border": {
-    "primary": {
-      "value": "#efecf4",
-      "type": "color"
-    },
-    "secondary": {
-      "value": "#e2dfe7",
-      "type": "color"
-    },
-    "muted": {
-      "value": "#7e7887",
-      "type": "color"
-    },
-    "active": {
-      "value": "#7e7887",
-      "type": "color"
-    },
-    "onMedia": {
-      "value": "#efecf41a",
-      "type": "color"
-    },
-    "ok": {
-      "value": "#2a929226",
-      "type": "color"
-    },
-    "error": {
-      "value": "#be467826",
-      "type": "color"
-    },
-    "warning": {
-      "value": "#a06e3b26",
-      "type": "color"
-    },
-    "info": {
-      "value": "#576ddb26",
-      "type": "color"
-    }
-  },
-  "editor": {
-    "background": {
-      "value": "#efecf4",
-      "type": "color"
-    },
-    "indent_guide": {
-      "value": "#7e7887",
-      "type": "color"
-    },
-    "indent_guide_active": {
-      "value": "#e2dfe7",
-      "type": "color"
-    },
-    "line": {
-      "active": {
-        "value": "#19171c12",
-        "type": "color"
-      },
-      "highlighted": {
-        "value": "#19171c1f",
-        "type": "color"
-      },
-      "inserted": {
-        "value": "#2a929240",
-        "type": "color"
-      },
-      "deleted": {
-        "value": "#be467840",
-        "type": "color"
-      },
-      "modified": {
-        "value": "#576ddb40",
-        "type": "color"
-      }
-    },
-    "highlight": {
-      "selection": {
-        "value": "#576ddb3d",
-        "type": "color"
-      },
-      "occurrence": {
-        "value": "#19171c0f",
-        "type": "color"
-      },
-      "activeOccurrence": {
-        "value": "#19171c1f",
-        "type": "color"
-      },
-      "matchingBracket": {
-        "value": "#e2dfe72e",
-        "type": "color"
-      },
-      "match": {
-        "value": "#955ae73d",
-        "type": "color"
-      },
-      "activeMatch": {
-        "value": "#955ae75c",
-        "type": "color"
-      },
-      "related": {
-        "value": "#e2dfe71f",
-        "type": "color"
-      }
-    },
-    "gutter": {
-      "primary": {
-        "value": "#655f6d",
-        "type": "color"
-      },
-      "active": {
-        "value": "#19171c",
-        "type": "color"
-      }
-    }
-  },
-  "syntax": {
-    "primary": {
-      "value": "#19171c",
-      "type": "color"
-    },
-    "comment": {
-      "value": "#585260",
-      "type": "color"
-    },
-    "keyword": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "function": {
-      "value": "#a06e3b",
-      "type": "color"
-    },
-    "type": {
-      "value": "#398bc6",
-      "type": "color"
-    },
-    "variant": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "property": {
-      "value": "#576ddb",
-      "type": "color"
-    },
-    "enum": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "operator": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "string": {
-      "value": "#aa573c",
-      "type": "color"
-    },
-    "number": {
-      "value": "#2a9292",
-      "type": "color"
-    },
-    "boolean": {
-      "value": "#2a9292",
-      "type": "color"
-    }
-  },
-  "player": {
-    "1": {
-      "baseColor": {
-        "value": "#576ddb",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#576ddb",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#576ddb3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#576ddbcc",
-        "type": "color"
-      }
-    },
-    "2": {
-      "baseColor": {
-        "value": "#2a9292",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#2a9292",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#2a92923d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#2a9292cc",
-        "type": "color"
-      }
-    },
-    "3": {
-      "baseColor": {
-        "value": "#bf40bf",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#bf40bf",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#bf40bf3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#bf40bfcc",
-        "type": "color"
-      }
-    },
-    "4": {
-      "baseColor": {
-        "value": "#aa573c",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#aa573c",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#aa573c3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#aa573ccc",
-        "type": "color"
-      }
-    },
-    "5": {
-      "baseColor": {
-        "value": "#955ae7",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#955ae7",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#955ae73d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#955ae7cc",
-        "type": "color"
-      }
-    },
-    "6": {
-      "baseColor": {
-        "value": "#398bc6",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#398bc6",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#398bc63d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#398bc6cc",
-        "type": "color"
-      }
-    },
-    "7": {
-      "baseColor": {
-        "value": "#be4678",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#be4678",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#be46783d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#be4678cc",
-        "type": "color"
-      }
-    },
-    "8": {
-      "baseColor": {
-        "value": "#a06e3b",
-        "type": "color"
-      },
-      "cursorColor": {
-        "value": "#a06e3b",
-        "type": "color"
-      },
-      "selectionColor": {
-        "value": "#a06e3b3d",
-        "type": "color"
-      },
-      "borderColor": {
-        "value": "#a06e3bcc",
-        "type": "color"
-      }
-    }
-  },
-  "shadowAlpha": {
-    "value": 0.12,
-    "type": "number"
-  }
-}

styles/dist/solarized-dark.json 🔗

@@ -89,15 +89,15 @@
   "background": {
     "100": {
       "base": {
-        "value": "#073642",
+        "value": "#1b444f",
         "type": "color"
       },
       "hovered": {
-        "value": "#586e753d",
+        "value": "#30525c",
         "type": "color"
       },
       "active": {
-        "value": "#586e755c",
+        "value": "#446068",
         "type": "color"
       }
     },
@@ -107,11 +107,11 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#586e753d",
+        "value": "#1b444f",
         "type": "color"
       },
       "active": {
-        "value": "#586e755c",
+        "value": "#30525c",
         "type": "color"
       }
     },
@@ -121,11 +121,11 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#0736423d",
+        "value": "#022e39",
         "type": "color"
       },
       "active": {
-        "value": "#0736425c",
+        "value": "#04313c",
         "type": "color"
       }
     },
@@ -135,25 +135,25 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#0736423d",
+        "value": "#022e39",
         "type": "color"
       },
       "active": {
-        "value": "#0736427a",
+        "value": "#04313c",
         "type": "color"
       }
     },
     "on500": {
       "base": {
-        "value": "#073642",
+        "value": "#1b444f",
         "type": "color"
       },
       "hovered": {
-        "value": "#586e753d",
+        "value": "#30525c",
         "type": "color"
       },
       "active": {
-        "value": "#586e757a",
+        "value": "#446068",
         "type": "color"
       }
     },
@@ -267,23 +267,11 @@
     },
     "line": {
       "active": {
-        "value": "#fdf6e312",
+        "value": "#073642",
         "type": "color"
       },
       "highlighted": {
-        "value": "#fdf6e31f",
-        "type": "color"
-      },
-      "inserted": {
-        "value": "#85990040",
-        "type": "color"
-      },
-      "deleted": {
-        "value": "#dc322f40",
-        "type": "color"
-      },
-      "modified": {
-        "value": "#268bd240",
+        "value": "#1b444f",
         "type": "color"
       }
     },
@@ -293,27 +281,27 @@
         "type": "color"
       },
       "occurrence": {
-        "value": "#fdf6e31f",
+        "value": "#586e753d",
         "type": "color"
       },
       "activeOccurrence": {
-        "value": "#fdf6e33d",
+        "value": "#586e757a",
         "type": "color"
       },
       "matchingBracket": {
-        "value": "#0736425c",
+        "value": "#04313c",
         "type": "color"
       },
       "match": {
-        "value": "#6c71c47a",
+        "value": "#1b1f6b",
         "type": "color"
       },
       "activeMatch": {
-        "value": "#6c71c4b8",
+        "value": "#434abc7a",
         "type": "color"
       },
       "related": {
-        "value": "#0736423d",
+        "value": "#022e39",
         "type": "color"
       }
     },

styles/dist/solarized-light.json 🔗

@@ -89,15 +89,15 @@
   "background": {
     "100": {
       "base": {
-        "value": "#eee8d5",
+        "value": "#d7d6c8",
         "type": "color"
       },
       "hovered": {
-        "value": "#93a1a11f",
+        "value": "#c1c5bb",
         "type": "color"
       },
       "active": {
-        "value": "#93a1a12e",
+        "value": "#aab3ae",
         "type": "color"
       }
     },
@@ -107,11 +107,11 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#93a1a11f",
+        "value": "#d7d6c8",
         "type": "color"
       },
       "active": {
-        "value": "#93a1a12e",
+        "value": "#c1c5bb",
         "type": "color"
       }
     },
@@ -121,11 +121,11 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#eee8d51f",
+        "value": "#f9f3e0",
         "type": "color"
       },
       "active": {
-        "value": "#eee8d52e",
+        "value": "#f6efdc",
         "type": "color"
       }
     },
@@ -135,25 +135,25 @@
         "type": "color"
       },
       "hovered": {
-        "value": "#eee8d51f",
+        "value": "#f9f3e0",
         "type": "color"
       },
       "active": {
-        "value": "#eee8d53d",
+        "value": "#f6efdc",
         "type": "color"
       }
     },
     "on500": {
       "base": {
-        "value": "#eee8d5",
+        "value": "#d7d6c8",
         "type": "color"
       },
       "hovered": {
-        "value": "#93a1a11f",
+        "value": "#c1c5bb",
         "type": "color"
       },
       "active": {
-        "value": "#93a1a13d",
+        "value": "#aab3ae",
         "type": "color"
       }
     },
@@ -216,19 +216,19 @@
   },
   "border": {
     "primary": {
-      "value": "#fdf6e3",
+      "value": "#93a1a1",
       "type": "color"
     },
     "secondary": {
-      "value": "#eee8d5",
+      "value": "#93a1a1",
       "type": "color"
     },
     "muted": {
-      "value": "#839496",
+      "value": "#657b83",
       "type": "color"
     },
     "active": {
-      "value": "#839496",
+      "value": "#657b83",
       "type": "color"
     },
     "onMedia": {
@@ -258,32 +258,20 @@
       "type": "color"
     },
     "indent_guide": {
-      "value": "#839496",
+      "value": "#657b83",
       "type": "color"
     },
     "indent_guide_active": {
-      "value": "#eee8d5",
+      "value": "#93a1a1",
       "type": "color"
     },
     "line": {
       "active": {
-        "value": "#002b3612",
+        "value": "#eee8d5",
         "type": "color"
       },
       "highlighted": {
-        "value": "#002b361f",
-        "type": "color"
-      },
-      "inserted": {
-        "value": "#85990040",
-        "type": "color"
-      },
-      "deleted": {
-        "value": "#dc322f40",
-        "type": "color"
-      },
-      "modified": {
-        "value": "#268bd240",
+        "value": "#d7d6c8",
         "type": "color"
       }
     },
@@ -293,27 +281,27 @@
         "type": "color"
       },
       "occurrence": {
-        "value": "#002b360f",
+        "value": "#93a1a11f",
         "type": "color"
       },
       "activeOccurrence": {
-        "value": "#002b361f",
+        "value": "#93a1a13d",
         "type": "color"
       },
       "matchingBracket": {
-        "value": "#eee8d52e",
+        "value": "#f6efdc",
         "type": "color"
       },
       "match": {
-        "value": "#6c71c43d",
+        "value": "#bcc0f6",
         "type": "color"
       },
       "activeMatch": {
-        "value": "#6c71c45c",
+        "value": "#7f84d73d",
         "type": "color"
       },
       "related": {
-        "value": "#eee8d51f",
+        "value": "#f9f3e0",
         "type": "color"
       }
     },

styles/dist/tokens.json 🔗

@@ -1271,15 +1271,15 @@
     "background": {
       "100": {
         "base": {
-          "value": "#26232a",
+          "value": "#332f38",
           "type": "color"
         },
         "hovered": {
-          "value": "#5852603d",
+          "value": "#3f3b45",
           "type": "color"
         },
         "active": {
-          "value": "#5852605c",
+          "value": "#4c4653",
           "type": "color"
         }
       },
@@ -1289,11 +1289,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#5852603d",
+          "value": "#332f38",
           "type": "color"
         },
         "active": {
-          "value": "#5852605c",
+          "value": "#3f3b45",
           "type": "color"
         }
       },
@@ -1303,11 +1303,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#26232a3d",
+          "value": "#1c1a20",
           "type": "color"
         },
         "active": {
-          "value": "#26232a5c",
+          "value": "#201d23",
           "type": "color"
         }
       },
@@ -1317,25 +1317,25 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#26232a3d",
+          "value": "#1c1a20",
           "type": "color"
         },
         "active": {
-          "value": "#26232a7a",
+          "value": "#201d23",
           "type": "color"
         }
       },
       "on500": {
         "base": {
-          "value": "#26232a",
+          "value": "#332f38",
           "type": "color"
         },
         "hovered": {
-          "value": "#5852603d",
+          "value": "#3f3b45",
           "type": "color"
         },
         "active": {
-          "value": "#5852607a",
+          "value": "#4c4653",
           "type": "color"
         }
       },
@@ -1449,23 +1449,11 @@
       },
       "line": {
         "active": {
-          "value": "#efecf412",
+          "value": "#26232a",
           "type": "color"
         },
         "highlighted": {
-          "value": "#efecf41f",
-          "type": "color"
-        },
-        "inserted": {
-          "value": "#2a929240",
-          "type": "color"
-        },
-        "deleted": {
-          "value": "#be467840",
-          "type": "color"
-        },
-        "modified": {
-          "value": "#576ddb40",
+          "value": "#332f38",
           "type": "color"
         }
       },
@@ -1475,27 +1463,27 @@
           "type": "color"
         },
         "occurrence": {
-          "value": "#efecf41f",
+          "value": "#5852603d",
           "type": "color"
         },
         "activeOccurrence": {
-          "value": "#efecf43d",
+          "value": "#5852607a",
           "type": "color"
         },
         "matchingBracket": {
-          "value": "#26232a5c",
+          "value": "#201d23",
           "type": "color"
         },
         "match": {
-          "value": "#955ae77a",
+          "value": "#3d1576",
           "type": "color"
         },
         "activeMatch": {
-          "value": "#955ae7b8",
+          "value": "#782edf7a",
           "type": "color"
         },
         "related": {
-          "value": "#26232a3d",
+          "value": "#1c1a20",
           "type": "color"
         }
       },
@@ -1802,15 +1790,15 @@
     "background": {
       "100": {
         "base": {
-          "value": "#e2dfe7",
+          "value": "#ccc9d2",
           "type": "color"
         },
         "hovered": {
-          "value": "#8b87921f",
+          "value": "#b7b3bd",
           "type": "color"
         },
         "active": {
-          "value": "#8b87922e",
+          "value": "#a19da7",
           "type": "color"
         }
       },
@@ -1820,11 +1808,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#8b87921f",
+          "value": "#ccc9d2",
           "type": "color"
         },
         "active": {
-          "value": "#8b87922e",
+          "value": "#b7b3bd",
           "type": "color"
         }
       },
@@ -1834,11 +1822,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#e2dfe71f",
+          "value": "#ece9f1",
           "type": "color"
         },
         "active": {
-          "value": "#e2dfe72e",
+          "value": "#e9e6ee",
           "type": "color"
         }
       },
@@ -1848,25 +1836,25 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#e2dfe71f",
+          "value": "#ece9f1",
           "type": "color"
         },
         "active": {
-          "value": "#e2dfe73d",
+          "value": "#e9e6ee",
           "type": "color"
         }
       },
       "on500": {
         "base": {
-          "value": "#e2dfe7",
+          "value": "#ccc9d2",
           "type": "color"
         },
         "hovered": {
-          "value": "#8b87921f",
+          "value": "#b7b3bd",
           "type": "color"
         },
         "active": {
-          "value": "#8b87923d",
+          "value": "#a19da7",
           "type": "color"
         }
       },
@@ -1929,19 +1917,19 @@
     },
     "border": {
       "primary": {
-        "value": "#efecf4",
+        "value": "#8b8792",
         "type": "color"
       },
       "secondary": {
-        "value": "#e2dfe7",
+        "value": "#8b8792",
         "type": "color"
       },
       "muted": {
-        "value": "#7e7887",
+        "value": "#655f6d",
         "type": "color"
       },
       "active": {
-        "value": "#7e7887",
+        "value": "#655f6d",
         "type": "color"
       },
       "onMedia": {
@@ -1971,32 +1959,20 @@
         "type": "color"
       },
       "indent_guide": {
-        "value": "#7e7887",
+        "value": "#655f6d",
         "type": "color"
       },
       "indent_guide_active": {
-        "value": "#e2dfe7",
+        "value": "#8b8792",
         "type": "color"
       },
       "line": {
         "active": {
-          "value": "#19171c12",
+          "value": "#e2dfe7",
           "type": "color"
         },
         "highlighted": {
-          "value": "#19171c1f",
-          "type": "color"
-        },
-        "inserted": {
-          "value": "#2a929240",
-          "type": "color"
-        },
-        "deleted": {
-          "value": "#be467840",
-          "type": "color"
-        },
-        "modified": {
-          "value": "#576ddb40",
+          "value": "#ccc9d2",
           "type": "color"
         }
       },
@@ -2006,27 +1982,27 @@
           "type": "color"
         },
         "occurrence": {
-          "value": "#19171c0f",
+          "value": "#8b87921f",
           "type": "color"
         },
         "activeOccurrence": {
-          "value": "#19171c1f",
+          "value": "#8b87923d",
           "type": "color"
         },
         "matchingBracket": {
-          "value": "#e2dfe72e",
+          "value": "#e9e6ee",
           "type": "color"
         },
         "match": {
-          "value": "#955ae73d",
+          "value": "#d5bdfa",
           "type": "color"
         },
         "activeMatch": {
-          "value": "#955ae75c",
+          "value": "#a775ee3d",
           "type": "color"
         },
         "related": {
-          "value": "#e2dfe71f",
+          "value": "#ece9f1",
           "type": "color"
         }
       },
@@ -2333,15 +2309,15 @@
     "background": {
       "100": {
         "base": {
-          "value": "#073642",
+          "value": "#1b444f",
           "type": "color"
         },
         "hovered": {
-          "value": "#586e753d",
+          "value": "#30525c",
           "type": "color"
         },
         "active": {
-          "value": "#586e755c",
+          "value": "#446068",
           "type": "color"
         }
       },
@@ -2351,11 +2327,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#586e753d",
+          "value": "#1b444f",
           "type": "color"
         },
         "active": {
-          "value": "#586e755c",
+          "value": "#30525c",
           "type": "color"
         }
       },
@@ -2365,11 +2341,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#0736423d",
+          "value": "#022e39",
           "type": "color"
         },
         "active": {
-          "value": "#0736425c",
+          "value": "#04313c",
           "type": "color"
         }
       },
@@ -2379,25 +2355,25 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#0736423d",
+          "value": "#022e39",
           "type": "color"
         },
         "active": {
-          "value": "#0736427a",
+          "value": "#04313c",
           "type": "color"
         }
       },
       "on500": {
         "base": {
-          "value": "#073642",
+          "value": "#1b444f",
           "type": "color"
         },
         "hovered": {
-          "value": "#586e753d",
+          "value": "#30525c",
           "type": "color"
         },
         "active": {
-          "value": "#586e757a",
+          "value": "#446068",
           "type": "color"
         }
       },
@@ -2511,23 +2487,11 @@
       },
       "line": {
         "active": {
-          "value": "#fdf6e312",
+          "value": "#073642",
           "type": "color"
         },
         "highlighted": {
-          "value": "#fdf6e31f",
-          "type": "color"
-        },
-        "inserted": {
-          "value": "#85990040",
-          "type": "color"
-        },
-        "deleted": {
-          "value": "#dc322f40",
-          "type": "color"
-        },
-        "modified": {
-          "value": "#268bd240",
+          "value": "#1b444f",
           "type": "color"
         }
       },
@@ -2537,27 +2501,27 @@
           "type": "color"
         },
         "occurrence": {
-          "value": "#fdf6e31f",
+          "value": "#586e753d",
           "type": "color"
         },
         "activeOccurrence": {
-          "value": "#fdf6e33d",
+          "value": "#586e757a",
           "type": "color"
         },
         "matchingBracket": {
-          "value": "#0736425c",
+          "value": "#04313c",
           "type": "color"
         },
         "match": {
-          "value": "#6c71c47a",
+          "value": "#1b1f6b",
           "type": "color"
         },
         "activeMatch": {
-          "value": "#6c71c4b8",
+          "value": "#434abc7a",
           "type": "color"
         },
         "related": {
-          "value": "#0736423d",
+          "value": "#022e39",
           "type": "color"
         }
       },
@@ -2864,15 +2828,15 @@
     "background": {
       "100": {
         "base": {
-          "value": "#eee8d5",
+          "value": "#d7d6c8",
           "type": "color"
         },
         "hovered": {
-          "value": "#93a1a11f",
+          "value": "#c1c5bb",
           "type": "color"
         },
         "active": {
-          "value": "#93a1a12e",
+          "value": "#aab3ae",
           "type": "color"
         }
       },
@@ -2882,11 +2846,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#93a1a11f",
+          "value": "#d7d6c8",
           "type": "color"
         },
         "active": {
-          "value": "#93a1a12e",
+          "value": "#c1c5bb",
           "type": "color"
         }
       },
@@ -2896,11 +2860,11 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#eee8d51f",
+          "value": "#f9f3e0",
           "type": "color"
         },
         "active": {
-          "value": "#eee8d52e",
+          "value": "#f6efdc",
           "type": "color"
         }
       },
@@ -2910,25 +2874,25 @@
           "type": "color"
         },
         "hovered": {
-          "value": "#eee8d51f",
+          "value": "#f9f3e0",
           "type": "color"
         },
         "active": {
-          "value": "#eee8d53d",
+          "value": "#f6efdc",
           "type": "color"
         }
       },
       "on500": {
         "base": {
-          "value": "#eee8d5",
+          "value": "#d7d6c8",
           "type": "color"
         },
         "hovered": {
-          "value": "#93a1a11f",
+          "value": "#c1c5bb",
           "type": "color"
         },
         "active": {
-          "value": "#93a1a13d",
+          "value": "#aab3ae",
           "type": "color"
         }
       },
@@ -2991,19 +2955,19 @@
     },
     "border": {
       "primary": {
-        "value": "#fdf6e3",
+        "value": "#93a1a1",
         "type": "color"
       },
       "secondary": {
-        "value": "#eee8d5",
+        "value": "#93a1a1",
         "type": "color"
       },
       "muted": {
-        "value": "#839496",
+        "value": "#657b83",
         "type": "color"
       },
       "active": {
-        "value": "#839496",
+        "value": "#657b83",
         "type": "color"
       },
       "onMedia": {
@@ -3033,32 +2997,20 @@
         "type": "color"
       },
       "indent_guide": {
-        "value": "#839496",
+        "value": "#657b83",
         "type": "color"
       },
       "indent_guide_active": {
-        "value": "#eee8d5",
+        "value": "#93a1a1",
         "type": "color"
       },
       "line": {
         "active": {
-          "value": "#002b3612",
+          "value": "#eee8d5",
           "type": "color"
         },
         "highlighted": {
-          "value": "#002b361f",
-          "type": "color"
-        },
-        "inserted": {
-          "value": "#85990040",
-          "type": "color"
-        },
-        "deleted": {
-          "value": "#dc322f40",
-          "type": "color"
-        },
-        "modified": {
-          "value": "#268bd240",
+          "value": "#d7d6c8",
           "type": "color"
         }
       },
@@ -3068,27 +3020,27 @@
           "type": "color"
         },
         "occurrence": {
-          "value": "#002b360f",
+          "value": "#93a1a11f",
           "type": "color"
         },
         "activeOccurrence": {
-          "value": "#002b361f",
+          "value": "#93a1a13d",
           "type": "color"
         },
         "matchingBracket": {
-          "value": "#eee8d52e",
+          "value": "#f6efdc",
           "type": "color"
         },
         "match": {
-          "value": "#6c71c43d",
+          "value": "#bcc0f6",
           "type": "color"
         },
         "activeMatch": {
-          "value": "#6c71c45c",
+          "value": "#7f84d73d",
           "type": "color"
         },
         "related": {
-          "value": "#eee8d51f",
+          "value": "#f9f3e0",
           "type": "color"
         }
       },
@@ -3303,5 +3255,1043 @@
       "value": 0.12,
       "type": "number"
     }
+  },
+  "sulphurpool-dark": {
+    "meta": {
+      "themeName": "sulphurpool-dark"
+    },
+    "text": {
+      "primary": {
+        "value": "#dfe2f1",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "placeholder": {
+        "value": "#898ea4",
+        "type": "color"
+      },
+      "active": {
+        "value": "#f5f7ff",
+        "type": "color"
+      },
+      "feature": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c94922",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd1",
+        "type": "color"
+      }
+    },
+    "icon": {
+      "primary": {
+        "value": "#dfe2f1",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "placeholder": {
+        "value": "#898ea4",
+        "type": "color"
+      },
+      "active": {
+        "value": "#f5f7ff",
+        "type": "color"
+      },
+      "feature": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c94922",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd1",
+        "type": "color"
+      }
+    },
+    "background": {
+      "100": {
+        "base": {
+          "value": "#363f62",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#444c6f",
+          "type": "color"
+        },
+        "active": {
+          "value": "#51597b",
+          "type": "color"
+        }
+      },
+      "300": {
+        "base": {
+          "value": "#293256",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#363f62",
+          "type": "color"
+        },
+        "active": {
+          "value": "#444c6f",
+          "type": "color"
+        }
+      },
+      "500": {
+        "base": {
+          "value": "#202746",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#222a4a",
+          "type": "color"
+        },
+        "active": {
+          "value": "#252d4e",
+          "type": "color"
+        }
+      },
+      "on300": {
+        "base": {
+          "value": "#202746",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#222a4a",
+          "type": "color"
+        },
+        "active": {
+          "value": "#252d4e",
+          "type": "color"
+        }
+      },
+      "on500": {
+        "base": {
+          "value": "#363f62",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#444c6f",
+          "type": "color"
+        },
+        "active": {
+          "value": "#51597b",
+          "type": "color"
+        }
+      },
+      "ok": {
+        "base": {
+          "value": "#ac973926",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#ac973933",
+          "type": "color"
+        },
+        "active": {
+          "value": "#ac973940",
+          "type": "color"
+        }
+      },
+      "error": {
+        "base": {
+          "value": "#c9492226",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#c9492233",
+          "type": "color"
+        },
+        "active": {
+          "value": "#c9492240",
+          "type": "color"
+        }
+      },
+      "warning": {
+        "base": {
+          "value": "#c08b3026",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#c08b3033",
+          "type": "color"
+        },
+        "active": {
+          "value": "#c08b3040",
+          "type": "color"
+        }
+      },
+      "info": {
+        "base": {
+          "value": "#3d8fd126",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#3d8fd133",
+          "type": "color"
+        },
+        "active": {
+          "value": "#3d8fd140",
+          "type": "color"
+        }
+      }
+    },
+    "border": {
+      "primary": {
+        "value": "#202746",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#293256",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "active": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "onMedia": {
+        "value": "#2027461a",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac973926",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c9492226",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b3026",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd126",
+        "type": "color"
+      }
+    },
+    "editor": {
+      "background": {
+        "value": "#202746",
+        "type": "color"
+      },
+      "indent_guide": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "indent_guide_active": {
+        "value": "#293256",
+        "type": "color"
+      },
+      "line": {
+        "active": {
+          "value": "#293256",
+          "type": "color"
+        },
+        "highlighted": {
+          "value": "#363f62",
+          "type": "color"
+        }
+      },
+      "highlight": {
+        "selection": {
+          "value": "#3d8fd13d",
+          "type": "color"
+        },
+        "occurrence": {
+          "value": "#5e66873d",
+          "type": "color"
+        },
+        "activeOccurrence": {
+          "value": "#5e66877a",
+          "type": "color"
+        },
+        "matchingBracket": {
+          "value": "#252d4e",
+          "type": "color"
+        },
+        "match": {
+          "value": "#1a2a6d",
+          "type": "color"
+        },
+        "activeMatch": {
+          "value": "#3d56c47a",
+          "type": "color"
+        },
+        "related": {
+          "value": "#222a4a",
+          "type": "color"
+        }
+      },
+      "gutter": {
+        "primary": {
+          "value": "#898ea4",
+          "type": "color"
+        },
+        "active": {
+          "value": "#f5f7ff",
+          "type": "color"
+        }
+      }
+    },
+    "syntax": {
+      "primary": {
+        "value": "#f5f7ff",
+        "type": "color"
+      },
+      "comment": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "keyword": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "function": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "type": {
+        "value": "#22a2c9",
+        "type": "color"
+      },
+      "variant": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "property": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "enum": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "operator": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "string": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "number": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "boolean": {
+        "value": "#ac9739",
+        "type": "color"
+      }
+    },
+    "player": {
+      "1": {
+        "baseColor": {
+          "value": "#3d8fd1",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#3d8fd1",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#3d8fd13d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#3d8fd1cc",
+          "type": "color"
+        }
+      },
+      "2": {
+        "baseColor": {
+          "value": "#ac9739",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#ac9739",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#ac97393d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#ac9739cc",
+          "type": "color"
+        }
+      },
+      "3": {
+        "baseColor": {
+          "value": "#9c637a",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#9c637a",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#9c637a3d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#9c637acc",
+          "type": "color"
+        }
+      },
+      "4": {
+        "baseColor": {
+          "value": "#c76b29",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c76b29",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c76b293d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c76b29cc",
+          "type": "color"
+        }
+      },
+      "5": {
+        "baseColor": {
+          "value": "#6679cc",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#6679cc",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#6679cc3d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#6679cccc",
+          "type": "color"
+        }
+      },
+      "6": {
+        "baseColor": {
+          "value": "#22a2c9",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#22a2c9",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#22a2c93d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#22a2c9cc",
+          "type": "color"
+        }
+      },
+      "7": {
+        "baseColor": {
+          "value": "#c94922",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c94922",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c949223d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c94922cc",
+          "type": "color"
+        }
+      },
+      "8": {
+        "baseColor": {
+          "value": "#c08b30",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c08b30",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c08b303d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c08b30cc",
+          "type": "color"
+        }
+      }
+    },
+    "shadowAlpha": {
+      "value": 0.24,
+      "type": "number"
+    }
+  },
+  "sulphurpool-light": {
+    "meta": {
+      "themeName": "sulphurpool-light"
+    },
+    "text": {
+      "primary": {
+        "value": "#293256",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#5e6687",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#5e6687",
+        "type": "color"
+      },
+      "placeholder": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "active": {
+        "value": "#202746",
+        "type": "color"
+      },
+      "feature": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c94922",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd1",
+        "type": "color"
+      }
+    },
+    "icon": {
+      "primary": {
+        "value": "#293256",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#5e6687",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#5e6687",
+        "type": "color"
+      },
+      "placeholder": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "active": {
+        "value": "#202746",
+        "type": "color"
+      },
+      "feature": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c94922",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd1",
+        "type": "color"
+      }
+    },
+    "background": {
+      "100": {
+        "base": {
+          "value": "#cdd1e2",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#bbc0d3",
+          "type": "color"
+        },
+        "active": {
+          "value": "#a9aec3",
+          "type": "color"
+        }
+      },
+      "300": {
+        "base": {
+          "value": "#dfe2f1",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#cdd1e2",
+          "type": "color"
+        },
+        "active": {
+          "value": "#bbc0d3",
+          "type": "color"
+        }
+      },
+      "500": {
+        "base": {
+          "value": "#f5f7ff",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#f0f2fc",
+          "type": "color"
+        },
+        "active": {
+          "value": "#eaedf8",
+          "type": "color"
+        }
+      },
+      "on300": {
+        "base": {
+          "value": "#f5f7ff",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#f0f2fc",
+          "type": "color"
+        },
+        "active": {
+          "value": "#eaedf8",
+          "type": "color"
+        }
+      },
+      "on500": {
+        "base": {
+          "value": "#cdd1e2",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#bbc0d3",
+          "type": "color"
+        },
+        "active": {
+          "value": "#a9aec3",
+          "type": "color"
+        }
+      },
+      "ok": {
+        "base": {
+          "value": "#ac973926",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#ac973933",
+          "type": "color"
+        },
+        "active": {
+          "value": "#ac973940",
+          "type": "color"
+        }
+      },
+      "error": {
+        "base": {
+          "value": "#c9492226",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#c9492233",
+          "type": "color"
+        },
+        "active": {
+          "value": "#c9492240",
+          "type": "color"
+        }
+      },
+      "warning": {
+        "base": {
+          "value": "#c08b3026",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#c08b3033",
+          "type": "color"
+        },
+        "active": {
+          "value": "#c08b3040",
+          "type": "color"
+        }
+      },
+      "info": {
+        "base": {
+          "value": "#3d8fd126",
+          "type": "color"
+        },
+        "hovered": {
+          "value": "#3d8fd133",
+          "type": "color"
+        },
+        "active": {
+          "value": "#3d8fd140",
+          "type": "color"
+        }
+      }
+    },
+    "border": {
+      "primary": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "secondary": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "muted": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "active": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "onMedia": {
+        "value": "#f5f7ff1a",
+        "type": "color"
+      },
+      "ok": {
+        "value": "#ac973926",
+        "type": "color"
+      },
+      "error": {
+        "value": "#c9492226",
+        "type": "color"
+      },
+      "warning": {
+        "value": "#c08b3026",
+        "type": "color"
+      },
+      "info": {
+        "value": "#3d8fd126",
+        "type": "color"
+      }
+    },
+    "editor": {
+      "background": {
+        "value": "#f5f7ff",
+        "type": "color"
+      },
+      "indent_guide": {
+        "value": "#6b7394",
+        "type": "color"
+      },
+      "indent_guide_active": {
+        "value": "#979db4",
+        "type": "color"
+      },
+      "line": {
+        "active": {
+          "value": "#dfe2f1",
+          "type": "color"
+        },
+        "highlighted": {
+          "value": "#cdd1e2",
+          "type": "color"
+        }
+      },
+      "highlight": {
+        "selection": {
+          "value": "#3d8fd13d",
+          "type": "color"
+        },
+        "occurrence": {
+          "value": "#979db41f",
+          "type": "color"
+        },
+        "activeOccurrence": {
+          "value": "#979db43d",
+          "type": "color"
+        },
+        "matchingBracket": {
+          "value": "#eaedf8",
+          "type": "color"
+        },
+        "match": {
+          "value": "#bcc6f7",
+          "type": "color"
+        },
+        "activeMatch": {
+          "value": "#7b8ddc3d",
+          "type": "color"
+        },
+        "related": {
+          "value": "#f0f2fc",
+          "type": "color"
+        }
+      },
+      "gutter": {
+        "primary": {
+          "value": "#6b7394",
+          "type": "color"
+        },
+        "active": {
+          "value": "#202746",
+          "type": "color"
+        }
+      }
+    },
+    "syntax": {
+      "primary": {
+        "value": "#202746",
+        "type": "color"
+      },
+      "comment": {
+        "value": "#5e6687",
+        "type": "color"
+      },
+      "keyword": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "function": {
+        "value": "#c08b30",
+        "type": "color"
+      },
+      "type": {
+        "value": "#22a2c9",
+        "type": "color"
+      },
+      "variant": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "property": {
+        "value": "#3d8fd1",
+        "type": "color"
+      },
+      "enum": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "operator": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "string": {
+        "value": "#c76b29",
+        "type": "color"
+      },
+      "number": {
+        "value": "#ac9739",
+        "type": "color"
+      },
+      "boolean": {
+        "value": "#ac9739",
+        "type": "color"
+      }
+    },
+    "player": {
+      "1": {
+        "baseColor": {
+          "value": "#3d8fd1",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#3d8fd1",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#3d8fd13d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#3d8fd1cc",
+          "type": "color"
+        }
+      },
+      "2": {
+        "baseColor": {
+          "value": "#ac9739",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#ac9739",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#ac97393d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#ac9739cc",
+          "type": "color"
+        }
+      },
+      "3": {
+        "baseColor": {
+          "value": "#9c637a",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#9c637a",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#9c637a3d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#9c637acc",
+          "type": "color"
+        }
+      },
+      "4": {
+        "baseColor": {
+          "value": "#c76b29",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c76b29",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c76b293d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c76b29cc",
+          "type": "color"
+        }
+      },
+      "5": {
+        "baseColor": {
+          "value": "#6679cc",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#6679cc",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#6679cc3d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#6679cccc",
+          "type": "color"
+        }
+      },
+      "6": {
+        "baseColor": {
+          "value": "#22a2c9",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#22a2c9",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#22a2c93d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#22a2c9cc",
+          "type": "color"
+        }
+      },
+      "7": {
+        "baseColor": {
+          "value": "#c94922",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c94922",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c949223d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c94922cc",
+          "type": "color"
+        }
+      },
+      "8": {
+        "baseColor": {
+          "value": "#c08b30",
+          "type": "color"
+        },
+        "cursorColor": {
+          "value": "#c08b30",
+          "type": "color"
+        },
+        "selectionColor": {
+          "value": "#c08b303d",
+          "type": "color"
+        },
+        "borderColor": {
+          "value": "#c08b30cc",
+          "type": "color"
+        }
+      }
+    },
+    "shadowAlpha": {
+      "value": 0.12,
+      "type": "number"
+    }
   }
 }

styles/src/buildThemes.ts 🔗

@@ -1,17 +1,9 @@
 import * as fs from "fs";
 import * as path from "path";
 import app from "./styleTree/app";
-import { dark as caveDark, light as caveLight } from "./themes/cave";
-import { dark as solarizedDark, light as solarizedLight } from "./themes/solarized";
-import { dark as sulphurpoolDark, light as sulphurpoolLight } from "./themes/sulphurpool";
+import themes from "./themes";
 import snakeCase from "./utils/snakeCase";
 
-const themes = [
-  caveDark, caveLight,
-  solarizedDark, solarizedLight,
-  sulphurpoolDark, sulphurpoolLight
-];
-
 const themeDirectory = `${__dirname}/../../assets/themes/`;
 
 // Clear existing themes

styles/src/buildTokens.ts 🔗

@@ -1,9 +1,7 @@
 import * as fs from "fs";
 import * as path from "path";
-import { light as solarizedLight, dark as solarizedDark } from "./themes/solarized";
-// Use cave as "light" and "dark" themes
-import { light, dark } from "./themes/cave";
-import Theme from "./themes/theme";
+import themes from "./themes";
+import Theme from "./themes/common/theme";
 import { colors, fontFamilies, fontSizes, fontWeights, sizes } from "./tokens";
 
 // Organize theme tokens
@@ -50,6 +48,9 @@ const coreTokens = {
 const combinedTokens: any = {};
 
 const distPath = path.resolve(`${__dirname}/../dist`);
+for (const file of fs.readdirSync(distPath)) {
+  fs.unlinkSync(path.join(distPath, file));
+}
 
 // Add core tokens to the combined tokens and write `core.json`.
 // We write `core.json` as a separate file for the design team's convenience, but it isn't consumed by Figma Tokens directly.
@@ -60,7 +61,6 @@ combinedTokens.core = coreTokens;
 
 // Add each theme to the combined tokens and write ${theme}.json.
 // We write `${theme}.json` as a separate file for the design team's convenience, but it isn't consumed by Figma Tokens directly.
-let themes = [dark, light, solarizedDark, solarizedLight];
 themes.forEach((theme) => {
   const themePath = `${distPath}/${theme.name}.json`
   fs.writeFileSync(themePath, JSON.stringify(themeTokens(theme), null, 2));

styles/src/styleTree/app.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import chatPanel from "./chatPanel";
 import { text } from "./components";
 import contactFinder from "./contactFinder";

styles/src/styleTree/chatPanel.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { panel } from "./app";
 import {
   backgroundColor,

styles/src/styleTree/commandPalette.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { text, backgroundColor, border } from "./components";
 
 export default function commandPalette(theme: Theme) {

styles/src/styleTree/components.ts 🔗

@@ -1,5 +1,5 @@
 import chroma from "chroma-js";
-import Theme, { BackgroundColorSet } from "../themes/theme";
+import Theme, { BackgroundColorSet } from "../themes/common/theme";
 import { fontFamilies, fontSizes, FontWeight } from "../tokens";
 import { Color } from "../utils/color";
 

styles/src/styleTree/contactFinder.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import picker from "./picker";
 import { backgroundColor, iconColor } from "./components";
 

styles/src/styleTree/contactNotification.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { backgroundColor, iconColor, text } from "./components";
 
 const avatarSize = 12;

styles/src/styleTree/contactsPanel.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { panel } from "./app";
 import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
 
@@ -122,7 +122,7 @@ export default function contactsPanel(theme: Theme) {
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),
     },
-    sharedProjectRow: {
+    projectRow: {
       ...projectRow,
       background: backgroundColor(theme, 300),
       name: {
@@ -136,19 +136,5 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 300, "active"),
       }
     },
-    unsharedProjectRow: {
-      ...projectRow,
-      background: backgroundColor(theme, 300),
-      name: {
-        ...projectRow.name,
-        ...text(theme, "mono", "secondary", { size: "sm" }),
-      },
-      hover: {
-        background: backgroundColor(theme, 300, "hovered"),
-      },
-      active: {
-        background: backgroundColor(theme, 300, "active"),
-      }
-    }
   }
 }

styles/src/styleTree/editor.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import {
   backgroundColor,
   border,

styles/src/styleTree/picker.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { backgroundColor, border, player, shadow, text } from "./components";
 
 export default function picker(theme: Theme) {

styles/src/styleTree/projectPanel.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { panel } from "./app";
 import { backgroundColor, iconColor, player, text } from "./components";
 

styles/src/styleTree/search.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { backgroundColor, border, player, text } from "./components";
 
 export default function search(theme: Theme) {

styles/src/styleTree/statusBar.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { backgroundColor, border, iconColor, text } from "./components";
 import { workspaceBackground } from "./workspace";
 

styles/src/styleTree/workspace.ts 🔗

@@ -1,4 +1,4 @@
-import Theme from "../themes/theme";
+import Theme from "../themes/common/theme";
 import { backgroundColor, border, iconColor, shadow, text } from "./components";
 import statusBar from "./statusBar";
 
@@ -41,6 +41,14 @@ export default function workspace(theme: Theme) {
 
   return {
     background: backgroundColor(theme, 300),
+    joiningProjectAvatar: {
+      cornerRadius: 40,
+      width: 80,
+    },
+    joiningProjectMessage: {
+      padding: 12,
+      ...text(theme, "sans", "primary", { size: "lg" })
+    },
     leaderBorderOpacity: 0.7,
     leaderBorderWidth: 2.0,
     tab,

styles/src/themes.ts 🔗

@@ -0,0 +1,16 @@
+import fs from "fs";
+import path from "path";
+import Theme from "./themes/common/theme";
+
+const themes: Theme[] = [];
+export default themes;
+
+const themesPath = path.resolve(`${__dirname}/themes`);
+for (const fileName of fs.readdirSync(themesPath)) {
+  const filePath = path.join(themesPath, fileName);
+  if (fs.statSync(filePath).isFile()) {
+    const theme = require(filePath);
+    themes.push(theme.dark);
+    themes.push(theme.light);
+  }
+}

styles/src/themes/cave.ts 🔗

@@ -1,5 +1,5 @@
 import chroma from "chroma-js";
-import { colorRamp, createTheme } from "./base16";
+import { colorRamp, createTheme } from "./common/base16";
 
 const name = "cave";
 

styles/src/themes/base16.ts → styles/src/themes/common/base16.ts 🔗

@@ -1,7 +1,7 @@
 import chroma from "chroma-js";
 import { Scale, Color } from "chroma-js";
-import { color, ColorToken, fontWeights, NumberToken } from "../tokens";
-import { withOpacity } from "../utils/color";
+import { color, ColorToken, fontWeights, NumberToken } from "../../tokens";
+import { withOpacity } from "../../utils/color";
 import Theme, { buildPlayer, Syntax } from "./theme";
 
 export function colorRamp(color: Color): Scale {

styles/src/themes/theme.ts → styles/src/themes/common/theme.ts 🔗

@@ -1,5 +1,5 @@
-import { ColorToken, FontWeightToken, NumberToken } from "../tokens";
-import { withOpacity } from "../utils/color";
+import { ColorToken, FontWeightToken, NumberToken } from "../../tokens";
+import { withOpacity } from "../../utils/color";
 
 export interface SyntaxHighlightStyle {
   color: ColorToken;

styles/src/themes/gruvbox.ts 🔗

@@ -1,30 +0,0 @@
-import chroma from "chroma-js";
-import { createTheme } from "./base16";
-
-const name = "cave";
-
-const colors = {
-  "red": chroma("#be4678"),
-  "orange": chroma("#aa573c"),
-  "yellow": chroma("#a06e3b"),
-  "green": chroma("#2a9292"),
-  "cyan": chroma("#398bc6"),
-  "blue": chroma("#576ddb"),
-  "violet": chroma("#955ae7"),
-  "magenta": chroma("#bf40bf"),
-};
-
-const ramps = {
-  neutral: chroma.scale(["#19171c", "#26232a", "#585260", "#655f6d", "#7e7887", "#8b8792", "#e2dfe7", "#efecf4"]),
-  red: chroma.scale([colors.red.darken(3), colors.red, colors.red.brighten(3)]),
-  orange: chroma.scale([colors.orange.darken(3), colors.orange, colors.orange.brighten(3)]),
-  yellow: chroma.scale([colors.yellow.darken(3), colors.yellow, colors.yellow.brighten(3)]),
-  green: chroma.scale([colors.green.darken(3), colors.green, colors.green.brighten(3)]),
-  cyan: chroma.scale([colors.cyan.darken(3), colors.cyan, colors.cyan.brighten(3)]),
-  blue: chroma.scale([colors.blue.darken(3), colors.blue, colors.blue.brighten(3)]),
-  violet: chroma.scale([colors.violet.darken(3), colors.violet, colors.violet.brighten(3)]),
-  magenta: chroma.scale([colors.magenta.darken(3), colors.magenta, colors.magenta.brighten(3)]),
-}
-
-export const dark = createTheme(`${name}-dark`, false, ramps);
-export const light = createTheme(`${name}-light`, true, ramps);

styles/src/themes/solarized.ts 🔗

@@ -1,5 +1,5 @@
 import chroma from "chroma-js";
-import { colorRamp, createTheme } from "./base16";
+import { colorRamp, createTheme } from "./common/base16";
 
 const name = "solarized";
 

styles/src/themes/sulphurpool.ts 🔗

@@ -1,5 +1,5 @@
 import chroma from "chroma-js";
-import { colorRamp, createTheme } from "./base16";
+import { colorRamp, createTheme } from "./common/base16";
 
 const name = "sulphurpool";