Add remove channel method

Mikayla Maki and max created

Move test client fields into appstate and fix tests

Co-authored-by: max <max@zed.dev>

Change summary

crates/client/src/channel_store.rs                      |  12 
crates/client/src/client.rs                             |   5 
crates/collab/src/db.rs                                 | 194 +++++++--
crates/collab/src/db/tests.rs                           |  24 +
crates/collab/src/rpc.rs                                |  42 +
crates/collab/src/tests.rs                              | 106 +++-
crates/collab/src/tests/channel_tests.rs                |  31 +
crates/collab/src/tests/integration_tests.rs            | 232 +++++-----
crates/collab/src/tests/randomized_integration_tests.rs |  66 +-
crates/collab_ui/src/collab_ui.rs                       |   2 
crates/collab_ui/src/panel.rs                           |  59 ++
crates/collab_ui/src/panel/channel_modal.rs             |   8 
crates/rpc/proto/zed.proto                              |   6 
crates/rpc/src/proto.rs                                 |   2 
crates/workspace/src/workspace.rs                       |   1 
15 files changed, 534 insertions(+), 256 deletions(-)

Detailed changes

crates/client/src/channel_store.rs 🔗

@@ -51,6 +51,10 @@ impl ChannelStore {
         &self.channel_invitations
     }
 
+    pub fn channel_for_id(&self, channel_id: u64) -> Option<Arc<Channel>> {
+        self.channels.iter().find(|c| c.id == channel_id).cloned()
+    }
+
     pub fn create_channel(
         &self,
         name: &str,
@@ -103,6 +107,14 @@ impl ChannelStore {
         false
     }
 
+    pub fn remove_channel(&self, channel_id: u64) -> impl Future<Output = Result<()>> {
+        let client = self.client.clone();
+        async move {
+            client.request(proto::RemoveChannel { channel_id }).await?;
+            Ok(())
+        }
+    }
+
     pub fn remove_member(
         &self,
         channel_id: u64,

crates/client/src/client.rs 🔗

@@ -575,7 +575,10 @@ impl Client {
             }),
         );
         if prev_handler.is_some() {
-            panic!("registered handler for the same message twice");
+            panic!(
+                "registered handler for the same message {} twice",
+                std::any::type_name::<M>()
+            );
         }
 
         Subscription::Message {

crates/collab/src/db.rs 🔗

@@ -44,6 +44,7 @@ use serde::{Deserialize, Serialize};
 pub use signup::{Invite, NewSignup, WaitlistSummary};
 use sqlx::migrate::{Migrate, Migration, MigrationSource};
 use sqlx::Connection;
+use std::fmt::Write as _;
 use std::ops::{Deref, DerefMut};
 use std::path::Path;
 use std::time::Duration;
@@ -3131,6 +3132,74 @@ impl Database {
         .await
     }
 
+    pub async fn remove_channel(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+    ) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
+        self.transaction(move |tx| async move {
+            let tx = tx;
+
+            // Check if user is an admin
+            channel_member::Entity::find()
+                .filter(
+                    channel_member::Column::ChannelId
+                        .eq(channel_id)
+                        .and(channel_member::Column::UserId.eq(user_id))
+                        .and(channel_member::Column::Admin.eq(true)),
+                )
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?;
+
+            let mut descendants = self.get_channel_descendants([channel_id], &*tx).await?;
+
+            // Keep channels which have another active
+            let mut channels_to_keep = channel_parent::Entity::find()
+                .filter(
+                    channel_parent::Column::ChildId
+                        .is_in(descendants.keys().copied().filter(|&id| id != channel_id))
+                        .and(
+                            channel_parent::Column::ParentId.is_not_in(descendants.keys().copied()),
+                        ),
+                )
+                .stream(&*tx)
+                .await?;
+
+            while let Some(row) = channels_to_keep.next().await {
+                let row = row?;
+                descendants.remove(&row.child_id);
+            }
+
+            drop(channels_to_keep);
+
+            let channels_to_remove = descendants.keys().copied().collect::<Vec<_>>();
+
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryUserIds {
+                UserId,
+            }
+
+            let members_to_notify: Vec<UserId> = channel_member::Entity::find()
+                .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.iter().copied()))
+                .select_only()
+                .column(channel_member::Column::UserId)
+                .distinct()
+                .into_values::<_, QueryUserIds>()
+                .all(&*tx)
+                .await?;
+
+            // Channel members and parents should delete via cascade
+            channel::Entity::delete_many()
+                .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied()))
+                .exec(&*tx)
+                .await?;
+
+            Ok((channels_to_remove, members_to_notify))
+        })
+        .await
+    }
+
     pub async fn invite_channel_member(
         &self,
         channel_id: ChannelId,
@@ -3256,50 +3325,32 @@ impl Database {
         self.transaction(|tx| async move {
             let tx = tx;
 
-            // Breadth first list of all edges in this user's channels
-            let sql = r#"
-            WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS (
-                    SELECT channel_id as child_id, CAST(NULL as INTEGER) as parent_id, 0
-                    FROM channel_members
-                    WHERE user_id = $1 AND accepted
-                UNION
-                    SELECT channel_parents.child_id, channel_parents.parent_id, channel_tree.depth + 1
-                    FROM channel_parents, channel_tree
-                    WHERE channel_parents.parent_id = channel_tree.child_id
-            )
-            SELECT channel_tree.child_id, channel_tree.parent_id
-            FROM channel_tree
-            ORDER BY child_id, parent_id IS NOT NULL
-            "#;
-
-            #[derive(FromQueryResult, Debug, PartialEq)]
-            pub struct ChannelParent {
-                pub child_id: ChannelId,
-                pub parent_id: Option<ChannelId>,
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryChannelIds {
+                ChannelId,
             }
 
-            let stmt = Statement::from_sql_and_values(
-                self.pool.get_database_backend(),
-                sql,
-                vec![user_id.into()],
-            );
-
-            let mut parents_by_child_id = HashMap::default();
-            let mut parents = channel_parent::Entity::find()
-                .from_raw_sql(stmt)
-                .into_model::<ChannelParent>()
-                .stream(&*tx).await?;
-            while let Some(parent) = parents.next().await {
-                let parent = parent?;
-                parents_by_child_id.insert(parent.child_id, parent.parent_id);
-            }
+            let starting_channel_ids: Vec<ChannelId> = channel_member::Entity::find()
+                .filter(
+                    channel_member::Column::UserId
+                        .eq(user_id)
+                        .and(channel_member::Column::Accepted.eq(true)),
+                )
+                .select_only()
+                .column(channel_member::Column::ChannelId)
+                .into_values::<_, QueryChannelIds>()
+                .all(&*tx)
+                .await?;
 
-            drop(parents);
+            let parents_by_child_id = self
+                .get_channel_descendants(starting_channel_ids, &*tx)
+                .await?;
 
             let mut channels = Vec::with_capacity(parents_by_child_id.len());
             let mut rows = channel::Entity::find()
                 .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
-                .stream(&*tx).await?;
+                .stream(&*tx)
+                .await?;
 
             while let Some(row) = rows.next().await {
                 let row = row?;
@@ -3317,18 +3368,73 @@ impl Database {
         .await
     }
 
-    pub async fn get_channel(&self, channel_id: ChannelId) -> Result<Channel> {
+    async fn get_channel_descendants(
+        &self,
+        channel_ids: impl IntoIterator<Item = ChannelId>,
+        tx: &DatabaseTransaction,
+    ) -> Result<HashMap<ChannelId, Option<ChannelId>>> {
+        let mut values = String::new();
+        for id in channel_ids {
+            if !values.is_empty() {
+                values.push_str(", ");
+            }
+            write!(&mut values, "({})", id).unwrap();
+        }
+
+        if values.is_empty() {
+            return Ok(HashMap::default());
+        }
+
+        let sql = format!(
+            r#"
+            WITH RECURSIVE channel_tree(child_id, parent_id) AS (
+                    SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id
+                    FROM (VALUES {}) as root_ids
+                UNION
+                    SELECT channel_parents.child_id, channel_parents.parent_id
+                    FROM channel_parents, channel_tree
+                    WHERE channel_parents.parent_id = channel_tree.child_id
+            )
+            SELECT channel_tree.child_id, channel_tree.parent_id
+            FROM channel_tree
+            ORDER BY child_id, parent_id IS NOT NULL
+            "#,
+            values
+        );
+
+        #[derive(FromQueryResult, Debug, PartialEq)]
+        pub struct ChannelParent {
+            pub child_id: ChannelId,
+            pub parent_id: Option<ChannelId>,
+        }
+
+        let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
+
+        let mut parents_by_child_id = HashMap::default();
+        let mut parents = channel_parent::Entity::find()
+            .from_raw_sql(stmt)
+            .into_model::<ChannelParent>()
+            .stream(tx)
+            .await?;
+
+        while let Some(parent) = parents.next().await {
+            let parent = parent?;
+            parents_by_child_id.insert(parent.child_id, parent.parent_id);
+        }
+
+        Ok(parents_by_child_id)
+    }
+
+    pub async fn get_channel(&self, channel_id: ChannelId) -> Result<Option<Channel>> {
         self.transaction(|tx| async move {
             let tx = tx;
-            let channel = channel::Entity::find_by_id(channel_id)
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| anyhow!("no such channel"))?;
-            Ok(Channel {
+            let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
+
+            Ok(channel.map(|channel| Channel {
                 id: channel.id,
                 name: channel.name,
                 parent_id: None,
-            })
+            }))
         })
         .await
     }

crates/collab/src/db/tests.rs 🔗

@@ -918,6 +918,11 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
         .await
         .unwrap();
 
+    let cargo_ra_id = db
+        .create_channel("cargo-ra", Some(cargo_id), "7", a_id)
+        .await
+        .unwrap();
+
     let channels = db.get_channels(a_id).await.unwrap();
 
     assert_eq!(
@@ -952,9 +957,28 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
                 id: cargo_id,
                 name: "cargo".to_string(),
                 parent_id: Some(rust_id),
+            },
+            Channel {
+                id: cargo_ra_id,
+                name: "cargo-ra".to_string(),
+                parent_id: Some(cargo_id),
             }
         ]
     );
+
+    // Remove a single channel
+    db.remove_channel(crdb_id, a_id).await.unwrap();
+    assert!(db.get_channel(crdb_id).await.unwrap().is_none());
+
+    // Remove a channel tree
+    let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
+    channel_ids.sort();
+    assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
+    assert_eq!(user_ids, &[a_id]);
+
+    assert!(db.get_channel(rust_id).await.unwrap().is_none());
+    assert!(db.get_channel(cargo_id).await.unwrap().is_none());
+    assert!(db.get_channel(cargo_ra_id).await.unwrap().is_none());
 });
 
 test_both_dbs!(

crates/collab/src/rpc.rs 🔗

@@ -243,6 +243,7 @@ impl Server {
             .add_request_handler(remove_contact)
             .add_request_handler(respond_to_contact_request)
             .add_request_handler(create_channel)
+            .add_request_handler(remove_channel)
             .add_request_handler(invite_channel_member)
             .add_request_handler(remove_channel_member)
             .add_request_handler(respond_to_channel_invite)
@@ -529,7 +530,6 @@ impl Server {
                 this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
                 this.peer.send(connection_id, build_initial_channels_update(channels, channel_invites))?;
 
-
                 if let Some((code, count)) = invite_code {
                     this.peer.send(connection_id, proto::UpdateInviteInfo {
                         url: format!("{}{}", this.app_state.config.invite_link_prefix, code),
@@ -2101,7 +2101,6 @@ async fn create_channel(
     response: Response<proto::CreateChannel>,
     session: Session,
 ) -> Result<()> {
-    dbg!(&request);
     let db = session.db().await;
     let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
 
@@ -2132,6 +2131,35 @@ async fn create_channel(
     Ok(())
 }
 
+async fn remove_channel(
+    request: proto::RemoveChannel,
+    response: Response<proto::RemoveChannel>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+
+    let channel_id = request.channel_id;
+    let (removed_channels, member_ids) = db
+        .remove_channel(ChannelId::from_proto(channel_id), session.user_id)
+        .await?;
+    response.send(proto::Ack {})?;
+
+    // Notify members of removed channels
+    let mut update = proto::UpdateChannels::default();
+    update
+        .remove_channels
+        .extend(removed_channels.into_iter().map(|id| id.to_proto()));
+
+    let connection_pool = session.connection_pool().await;
+    for member_id in member_ids {
+        for connection_id in connection_pool.user_connection_ids(member_id) {
+            session.peer.send(connection_id, update.clone())?;
+        }
+    }
+
+    Ok(())
+}
+
 async fn invite_channel_member(
     request: proto::InviteChannelMember,
     response: Response<proto::InviteChannelMember>,
@@ -2139,7 +2167,10 @@ async fn invite_channel_member(
 ) -> Result<()> {
     let db = session.db().await;
     let channel_id = ChannelId::from_proto(request.channel_id);
-    let channel = db.get_channel(channel_id).await?;
+    let channel = db
+        .get_channel(channel_id)
+        .await?
+        .ok_or_else(|| anyhow!("channel not found"))?;
     let invitee_id = UserId::from_proto(request.user_id);
     db.invite_channel_member(channel_id, invitee_id, session.user_id, false)
         .await?;
@@ -2177,7 +2208,10 @@ async fn respond_to_channel_invite(
 ) -> Result<()> {
     let db = session.db().await;
     let channel_id = ChannelId::from_proto(request.channel_id);
-    let channel = db.get_channel(channel_id).await?;
+    let channel = db
+        .get_channel(channel_id)
+        .await?
+        .ok_or_else(|| anyhow!("no such channel"))?;
     db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
         .await?;
 

crates/collab/src/tests.rs 🔗

@@ -14,8 +14,8 @@ use collections::{HashMap, HashSet};
 use fs::FakeFs;
 use futures::{channel::oneshot, StreamExt as _};
 use gpui::{
-    elements::*, executor::Deterministic, AnyElement, Entity, ModelHandle, TestAppContext, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    elements::*, executor::Deterministic, AnyElement, Entity, ModelHandle, Task, TestAppContext,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use parking_lot::Mutex;
@@ -197,7 +197,7 @@ impl TestServer {
             languages: Arc::new(LanguageRegistry::test()),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),
-            initialize_workspace: |_, _, _, _| unimplemented!(),
+            initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
         });
 
@@ -218,13 +218,9 @@ impl TestServer {
             .unwrap();
 
         let client = TestClient {
-            client,
+            app_state,
             username: name.to_string(),
             state: Default::default(),
-            user_store,
-            channel_store,
-            fs,
-            language_registry: Arc::new(LanguageRegistry::test()),
         };
         client.wait_for_current_user(cx).await;
         client
@@ -252,6 +248,7 @@ impl TestServer {
             let (client_a, cx_a) = left.last_mut().unwrap();
             for (client_b, cx_b) in right {
                 client_a
+                    .app_state
                     .user_store
                     .update(*cx_a, |store, cx| {
                         store.request_contact(client_b.user_id().unwrap(), cx)
@@ -260,6 +257,7 @@ impl TestServer {
                     .unwrap();
                 cx_a.foreground().run_until_parked();
                 client_b
+                    .app_state
                     .user_store
                     .update(*cx_b, |store, cx| {
                         store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
@@ -278,6 +276,7 @@ impl TestServer {
     ) -> u64 {
         let (admin_client, admin_cx) = admin;
         let channel_id = admin_client
+            .app_state
             .channel_store
             .update(admin_cx, |channel_store, _| {
                 channel_store.create_channel(channel, None)
@@ -287,6 +286,7 @@ impl TestServer {
 
         for (member_client, member_cx) in members {
             admin_client
+                .app_state
                 .channel_store
                 .update(admin_cx, |channel_store, _| {
                     channel_store.invite_member(channel_id, member_client.user_id().unwrap(), false)
@@ -297,6 +297,7 @@ impl TestServer {
             admin_cx.foreground().run_until_parked();
 
             member_client
+                .app_state
                 .channel_store
                 .update(*member_cx, |channels, _| {
                     channels.respond_to_channel_invite(channel_id, true)
@@ -359,13 +360,9 @@ impl Drop for TestServer {
 }
 
 struct TestClient {
-    client: Arc<Client>,
     username: String,
     state: RefCell<TestClientState>,
-    pub user_store: ModelHandle<UserStore>,
-    pub channel_store: ModelHandle<ChannelStore>,
-    language_registry: Arc<LanguageRegistry>,
-    fs: Arc<FakeFs>,
+    app_state: Arc<workspace::AppState>,
 }
 
 #[derive(Default)]
@@ -379,7 +376,7 @@ impl Deref for TestClient {
     type Target = Arc<Client>;
 
     fn deref(&self) -> &Self::Target {
-        &self.client
+        &self.app_state.client
     }
 }
 
@@ -390,22 +387,45 @@ struct ContactsSummary {
 }
 
 impl TestClient {
+    pub fn fs(&self) -> &FakeFs {
+        self.app_state.fs.as_fake()
+    }
+
+    pub fn channel_store(&self) -> &ModelHandle<ChannelStore> {
+        &self.app_state.channel_store
+    }
+
+    pub fn user_store(&self) -> &ModelHandle<UserStore> {
+        &self.app_state.user_store
+    }
+
+    pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
+        &self.app_state.languages
+    }
+
+    pub fn client(&self) -> &Arc<Client> {
+        &self.app_state.client
+    }
+
     pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
         UserId::from_proto(
-            self.user_store
+            self.app_state
+                .user_store
                 .read_with(cx, |user_store, _| user_store.current_user().unwrap().id),
         )
     }
 
     async fn wait_for_current_user(&self, cx: &TestAppContext) {
         let mut authed_user = self
+            .app_state
             .user_store
             .read_with(cx, |user_store, _| user_store.watch_current_user());
         while authed_user.next().await.unwrap().is_none() {}
     }
 
     async fn clear_contacts(&self, cx: &mut TestAppContext) {
-        self.user_store
+        self.app_state
+            .user_store
             .update(cx, |store, _| store.clear_contacts())
             .await;
     }
@@ -443,23 +463,25 @@ impl TestClient {
     }
 
     fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
-        self.user_store.read_with(cx, |store, _| ContactsSummary {
-            current: store
-                .contacts()
-                .iter()
-                .map(|contact| contact.user.github_login.clone())
-                .collect(),
-            outgoing_requests: store
-                .outgoing_contact_requests()
-                .iter()
-                .map(|user| user.github_login.clone())
-                .collect(),
-            incoming_requests: store
-                .incoming_contact_requests()
-                .iter()
-                .map(|user| user.github_login.clone())
-                .collect(),
-        })
+        self.app_state
+            .user_store
+            .read_with(cx, |store, _| ContactsSummary {
+                current: store
+                    .contacts()
+                    .iter()
+                    .map(|contact| contact.user.github_login.clone())
+                    .collect(),
+                outgoing_requests: store
+                    .outgoing_contact_requests()
+                    .iter()
+                    .map(|user| user.github_login.clone())
+                    .collect(),
+                incoming_requests: store
+                    .incoming_contact_requests()
+                    .iter()
+                    .map(|user| user.github_login.clone())
+                    .collect(),
+            })
     }
 
     async fn build_local_project(
@@ -469,10 +491,10 @@ impl TestClient {
     ) -> (ModelHandle<Project>, WorktreeId) {
         let project = cx.update(|cx| {
             Project::local(
-                self.client.clone(),
-                self.user_store.clone(),
-                self.language_registry.clone(),
-                self.fs.clone(),
+                self.client().clone(),
+                self.app_state.user_store.clone(),
+                self.app_state.languages.clone(),
+                self.app_state.fs.clone(),
                 cx,
             )
         });
@@ -498,8 +520,8 @@ impl TestClient {
         room.update(guest_cx, |room, cx| {
             room.join_project(
                 host_project_id,
-                self.language_registry.clone(),
-                self.fs.clone(),
+                self.app_state.languages.clone(),
+                self.app_state.fs.clone(),
                 cx,
             )
         })
@@ -541,7 +563,9 @@ impl TestClient {
         // We use a workspace container so that we don't need to remove the window in order to
         // drop the workspace and we can use a ViewHandle instead.
         let (window_id, container) = cx.add_window(|_| WorkspaceContainer { workspace: None });
-        let workspace = cx.add_view(window_id, |cx| Workspace::test_new(project.clone(), cx));
+        let workspace = cx.add_view(window_id, |cx| {
+            Workspace::new(0, project.clone(), self.app_state.clone(), cx)
+        });
         container.update(cx, |container, cx| {
             container.workspace = Some(workspace.downgrade());
             cx.notify();
@@ -552,7 +576,7 @@ impl TestClient {
 
 impl Drop for TestClient {
     fn drop(&mut self) {
-        self.client.teardown();
+        self.app_state.client.teardown();
     }
 }
 

crates/collab/src/tests/channel_tests.rs 🔗

@@ -19,14 +19,14 @@ async fn test_basic_channels(
     let client_b = server.create_client(cx_b, "user_b").await;
 
     let channel_a_id = client_a
-        .channel_store
+        .channel_store()
         .update(cx_a, |channel_store, _| {
             channel_store.create_channel("channel-a", None)
         })
         .await
         .unwrap();
 
-    client_a.channel_store.read_with(cx_a, |channels, _| {
+    client_a.channel_store().read_with(cx_a, |channels, _| {
         assert_eq!(
             channels.channels(),
             &[Arc::new(Channel {
@@ -39,12 +39,12 @@ async fn test_basic_channels(
     });
 
     client_b
-        .channel_store
+        .channel_store()
         .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[]));
 
     // Invite client B to channel A as client A.
     client_a
-        .channel_store
+        .channel_store()
         .update(cx_a, |channel_store, _| {
             channel_store.invite_member(channel_a_id, client_b.user_id().unwrap(), false)
         })
@@ -54,7 +54,7 @@ async fn test_basic_channels(
     // Wait for client b to see the invitation
     deterministic.run_until_parked();
 
-    client_b.channel_store.read_with(cx_b, |channels, _| {
+    client_b.channel_store().read_with(cx_b, |channels, _| {
         assert_eq!(
             channels.channel_invitations(),
             &[Arc::new(Channel {
@@ -68,13 +68,13 @@ async fn test_basic_channels(
 
     // Client B now sees that they are in channel A.
     client_b
-        .channel_store
+        .channel_store()
         .update(cx_b, |channels, _| {
             channels.respond_to_channel_invite(channel_a_id, true)
         })
         .await
         .unwrap();
-    client_b.channel_store.read_with(cx_b, |channels, _| {
+    client_b.channel_store().read_with(cx_b, |channels, _| {
         assert_eq!(channels.channel_invitations(), &[]);
         assert_eq!(
             channels.channels(),
@@ -86,6 +86,23 @@ async fn test_basic_channels(
             })]
         )
     });
+
+    // Client A deletes the channel
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, _| {
+            channel_store.remove_channel(channel_a_id)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    client_a
+        .channel_store()
+        .read_with(cx_a, |channels, _| assert_eq!(channels.channels(), &[]));
+    client_b
+        .channel_store()
+        .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[]));
 }
 
 #[gpui::test]

crates/collab/src/tests/integration_tests.rs 🔗

@@ -749,7 +749,7 @@ async fn test_server_restarts(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     client_a
-        .fs
+        .fs()
         .insert_tree("/a", json!({ "a.txt": "a-contents" }))
         .await;
 
@@ -1221,7 +1221,7 @@ async fn test_share_project(
     let active_call_c = cx_c.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1388,7 +1388,7 @@ async fn test_unshare_project(
     let active_call_b = cx_b.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1477,7 +1477,7 @@ async fn test_host_disconnect(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1500,7 +1500,7 @@ async fn test_host_disconnect(
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
     let (window_id_b, workspace_b) =
-        cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "b.txt"), None, true, cx)
@@ -1584,7 +1584,7 @@ async fn test_project_reconnect(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -1612,7 +1612,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-2",
             json!({
@@ -1621,7 +1621,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-3",
             json!({
@@ -1701,7 +1701,7 @@ async fn test_project_reconnect(
 
     // While client A is disconnected, add and remove files from client A's project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1/dir1/subdir2",
             json!({
@@ -1713,7 +1713,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .remove_dir(
             "/root-1/dir1/subdir1".as_ref(),
             RemoveOptions {
@@ -1835,11 +1835,11 @@ async fn test_project_reconnect(
 
     // While client B is disconnected, add and remove files from client A's project
     client_a
-        .fs
+        .fs()
         .insert_file("/root-1/dir1/subdir2/j.txt", "j-contents".into())
         .await;
     client_a
-        .fs
+        .fs()
         .remove_file("/root-1/dir1/subdir2/i.txt".as_ref(), Default::default())
         .await
         .unwrap();
@@ -1925,8 +1925,8 @@ async fn test_active_call_events(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    client_a.fs.insert_tree("/a", json!({})).await;
-    client_b.fs.insert_tree("/b", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
+    client_b.fs().insert_tree("/b", json!({})).await;
 
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
@@ -2014,8 +2014,8 @@ async fn test_room_location(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    client_a.fs.insert_tree("/a", json!({})).await;
-    client_b.fs.insert_tree("/b", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
+    client_b.fs().insert_tree("/b", json!({})).await;
 
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
@@ -2204,12 +2204,12 @@ async fn test_propagate_saves_and_fs_changes(
         Some(tree_sitter_rust::language()),
     ));
     for client in [&client_a, &client_b, &client_c] {
-        client.language_registry.add(rust.clone());
-        client.language_registry.add(javascript.clone());
+        client.language_registry().add(rust.clone());
+        client.language_registry().add(javascript.clone());
     }
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -2279,7 +2279,7 @@ async fn test_propagate_saves_and_fs_changes(
     buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx));
     save_b.await.unwrap();
     assert_eq!(
-        client_a.fs.load("/a/file1.rs".as_ref()).await.unwrap(),
+        client_a.fs().load("/a/file1.rs".as_ref()).await.unwrap(),
         "hi-a, i-am-c, i-am-b, i-am-a"
     );
 
@@ -2290,7 +2290,7 @@ async fn test_propagate_saves_and_fs_changes(
 
     // Make changes on host's file system, see those changes on guest worktrees.
     client_a
-        .fs
+        .fs()
         .rename(
             "/a/file1.rs".as_ref(),
             "/a/file1.js".as_ref(),
@@ -2299,11 +2299,11 @@ async fn test_propagate_saves_and_fs_changes(
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
         .await
         .unwrap();
-    client_a.fs.insert_file("/a/file4", "4".into()).await;
+    client_a.fs().insert_file("/a/file4", "4".into()).await;
     deterministic.run_until_parked();
 
     worktree_a.read_with(cx_a, |tree, _| {
@@ -2397,7 +2397,7 @@ async fn test_git_diff_base_change(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2441,7 +2441,7 @@ async fn test_git_diff_base_change(
     "
     .unindent();
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/.git"),
         &[(Path::new("a.txt"), diff_base.clone())],
     );
@@ -2486,7 +2486,7 @@ async fn test_git_diff_base_change(
         );
     });
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/.git"),
         &[(Path::new("a.txt"), new_diff_base.clone())],
     );
@@ -2531,7 +2531,7 @@ async fn test_git_diff_base_change(
     "
     .unindent();
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/sub/.git"),
         &[(Path::new("b.txt"), diff_base.clone())],
     );
@@ -2576,7 +2576,7 @@ async fn test_git_diff_base_change(
         );
     });
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/sub/.git"),
         &[(Path::new("b.txt"), new_diff_base.clone())],
     );
@@ -2635,7 +2635,7 @@ async fn test_git_branch_name(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2654,8 +2654,7 @@ async fn test_git_branch_name(
 
     let project_remote = client_b.build_remote_project(project_id, cx_b).await;
     client_a
-        .fs
-        .as_fake()
+        .fs()
         .set_branch_name(Path::new("/dir/.git"), Some("branch-1"));
 
     // Wait for it to catch up to the new branch
@@ -2680,8 +2679,7 @@ async fn test_git_branch_name(
     });
 
     client_a
-        .fs
-        .as_fake()
+        .fs()
         .set_branch_name(Path::new("/dir/.git"), Some("branch-2"));
 
     // Wait for buffer_local_a to receive it
@@ -2720,7 +2718,7 @@ async fn test_git_status_sync(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2734,7 +2732,7 @@ async fn test_git_status_sync(
     const A_TXT: &'static str = "a.txt";
     const B_TXT: &'static str = "b.txt";
 
-    client_a.fs.as_fake().set_status_for_repo_via_git_operation(
+    client_a.fs().set_status_for_repo_via_git_operation(
         Path::new("/dir/.git"),
         &[
             (&Path::new(A_TXT), GitFileStatus::Added),
@@ -2780,16 +2778,13 @@ async fn test_git_status_sync(
         assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
     });
 
-    client_a
-        .fs
-        .as_fake()
-        .set_status_for_repo_via_working_copy_change(
-            Path::new("/dir/.git"),
-            &[
-                (&Path::new(A_TXT), GitFileStatus::Modified),
-                (&Path::new(B_TXT), GitFileStatus::Modified),
-            ],
-        );
+    client_a.fs().set_status_for_repo_via_working_copy_change(
+        Path::new("/dir/.git"),
+        &[
+            (&Path::new(A_TXT), GitFileStatus::Modified),
+            (&Path::new(B_TXT), GitFileStatus::Modified),
+        ],
+    );
 
     // Wait for buffer_local_a to receive it
     deterministic.run_until_parked();
@@ -2860,7 +2855,7 @@ async fn test_fs_operations(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3133,7 +3128,7 @@ async fn test_local_settings(
 
     // As client A, open a project that contains some local settings files
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3175,7 +3170,7 @@ async fn test_local_settings(
 
     // As client A, update a settings file. As Client B, see the changed settings.
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/.zed/settings.json", r#"{}"#.into())
         .await;
     deterministic.run_until_parked();
@@ -3192,17 +3187,17 @@ async fn test_local_settings(
 
     // As client A, create and remove some settings files. As client B, see the changed settings.
     client_a
-        .fs
+        .fs()
         .remove_file("/dir/.zed/settings.json".as_ref(), Default::default())
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .create_dir("/dir/b/.zed".as_ref())
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into())
         .await;
     deterministic.run_until_parked();
@@ -3223,11 +3218,11 @@ async fn test_local_settings(
 
     // As client A, change and remove settings files while client B is disconnected.
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into())
         .await;
     client_a
-        .fs
+        .fs()
         .remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default())
         .await
         .unwrap();
@@ -3261,7 +3256,7 @@ async fn test_buffer_conflict_after_save(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3323,7 +3318,7 @@ async fn test_buffer_reloading(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3351,7 +3346,7 @@ async fn test_buffer_reloading(
 
     let new_contents = Rope::from("d\ne\nf");
     client_a
-        .fs
+        .fs()
         .save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows)
         .await
         .unwrap();
@@ -3380,7 +3375,7 @@ async fn test_editing_while_guest_opens_buffer(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3429,7 +3424,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "Some text\n" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3527,7 +3522,7 @@ async fn test_leaving_worktree_while_opening_buffer(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3570,7 +3565,7 @@ async fn test_canceling_buffer_opening(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3626,7 +3621,7 @@ async fn test_leaving_project(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -3714,9 +3709,9 @@ async fn test_leaving_project(
     cx_b.spawn(|cx| {
         Project::remote(
             project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.language_registry.clone(),
+            client_b.app_state.client.clone(),
+            client_b.user_store().clone(),
+            client_b.language_registry().clone(),
             FakeFs::new(cx.background()),
             cx,
         )
@@ -3768,11 +3763,11 @@ async fn test_collaborating_with_diagnostics(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     // Share a project as client A
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -4040,11 +4035,11 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let file_names = &["one.rs", "two.rs", "three.rs", "four.rs", "five.rs"];
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/test",
             json!({
@@ -4181,10 +4176,10 @@ async fn test_collaborating_with_completion(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -4342,7 +4337,7 @@ async fn test_reloading_buffer_manually(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/a", json!({ "a.rs": "let one = 1;" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
@@ -4373,7 +4368,7 @@ async fn test_reloading_buffer_manually(
     buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;"));
 
     client_a
-        .fs
+        .fs()
         .save(
             "/a/a.rs".as_ref(),
             &Rope::from("let seven = 7;"),
@@ -4444,14 +4439,14 @@ async fn test_formatting_buffer(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     // Here we insert a fake tree with a directory that exists on disk. This is needed
     // because later we'll invoke a command, which requires passing a working directory
     // that points to a valid location on disk.
     let directory = env::current_dir().unwrap();
     client_a
-        .fs
+        .fs()
         .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
@@ -4553,10 +4548,10 @@ async fn test_definition(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4701,10 +4696,10 @@ async fn test_references(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4797,7 +4792,7 @@ async fn test_project_search(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4883,7 +4878,7 @@ async fn test_document_highlights(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -4902,7 +4897,7 @@ async fn test_document_highlights(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
     let project_id = active_call_a
@@ -4989,7 +4984,7 @@ async fn test_lsp_hover(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -5008,7 +5003,7 @@ async fn test_lsp_hover(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
     let project_id = active_call_a
@@ -5107,10 +5102,10 @@ async fn test_project_symbols(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/code",
             json!({
@@ -5218,10 +5213,10 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -5278,6 +5273,7 @@ async fn test_collaborating_with_code_actions(
     deterministic.forbid_parking();
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
+    //
     let client_b = server.create_client(cx_b, "user_b").await;
     server
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
@@ -5296,10 +5292,10 @@ async fn test_collaborating_with_code_actions(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -5316,7 +5312,8 @@ async fn test_collaborating_with_code_actions(
 
     // Join the project as client B.
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+    let (_window_b, workspace_b) =
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "main.rs"), None, true, cx)
@@ -5521,10 +5518,10 @@ async fn test_collaborating_with_renames(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -5540,7 +5537,8 @@ async fn test_collaborating_with_renames(
         .unwrap();
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
 
-    let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+    let (_window_b, workspace_b) =
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
             workspace.open_path((worktree_id, "one.rs"), None, true, cx)
@@ -5706,10 +5704,10 @@ async fn test_language_server_statuses(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -6166,7 +6164,7 @@ async fn test_contacts(
 
     // Test removing a contact
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.remove_contact(client_c.user_id().unwrap(), cx)
         })
@@ -6189,7 +6187,7 @@ async fn test_contacts(
         client: &TestClient,
         cx: &TestAppContext,
     ) -> Vec<(String, &'static str, &'static str)> {
-        client.user_store.read_with(cx, |store, _| {
+        client.user_store().read_with(cx, |store, _| {
             store
                 .contacts()
                 .iter()
@@ -6232,14 +6230,14 @@ async fn test_contact_requests(
 
     // User A and User C request that user B become their contact.
     client_a
-        .user_store
+        .user_store()
         .update(cx_a, |store, cx| {
             store.request_contact(client_b.user_id().unwrap(), cx)
         })
         .await
         .unwrap();
     client_c
-        .user_store
+        .user_store()
         .update(cx_c, |store, cx| {
             store.request_contact(client_b.user_id().unwrap(), cx)
         })
@@ -6293,7 +6291,7 @@ async fn test_contact_requests(
 
     // User B accepts the request from user A.
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
         })
@@ -6337,7 +6335,7 @@ async fn test_contact_requests(
 
     // User B rejects the request from user C.
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx)
         })
@@ -6419,7 +6417,7 @@ async fn test_basic_following(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -6980,7 +6978,7 @@ async fn test_join_call_after_screen_was_shared(
         .await
         .unwrap();
 
-    client_b.user_store.update(cx_b, |user_store, _| {
+    client_b.user_store().update(cx_b, |user_store, _| {
         user_store.clear_cache();
     });
 
@@ -7040,7 +7038,7 @@ async fn test_following_tab_order(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7163,7 +7161,7 @@ async fn test_peers_following_each_other(
 
     // Client A shares a project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7336,7 +7334,7 @@ async fn test_auto_unfollowing(
 
     // Client A shares a project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7500,7 +7498,7 @@ async fn test_peers_simultaneously_following_each_other(
     cx_a.update(editor::init);
     cx_b.update(editor::init);
 
-    client_a.fs.insert_tree("/a", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let workspace_a = client_a.build_workspace(&project_a, cx_a);
     let project_id = active_call_a
@@ -7577,10 +7575,10 @@ async fn test_on_input_format_from_host_to_guest(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7706,10 +7704,10 @@ async fn test_on_input_format_from_guest_to_host(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7862,11 +7860,11 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         }))
         .await;
     let language = Arc::new(language);
-    client_a.language_registry.add(Arc::clone(&language));
-    client_b.language_registry.add(language);
+    client_a.language_registry().add(Arc::clone(&language));
+    client_b.language_registry().add(language);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -8169,11 +8167,11 @@ async fn test_inlay_hint_refresh_is_forwarded(
         }))
         .await;
     let language = Arc::new(language);
-    client_a.language_registry.add(Arc::clone(&language));
-    client_b.language_registry.add(language);
+    client_a.language_registry().add(Arc::clone(&language));
+    client_b.language_registry().add(language);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({

crates/collab/src/tests/randomized_integration_tests.rs 🔗

@@ -396,9 +396,9 @@ async fn apply_client_operation(
             );
 
             let root_path = Path::new("/").join(&first_root_name);
-            client.fs.create_dir(&root_path).await.unwrap();
+            client.fs().create_dir(&root_path).await.unwrap();
             client
-                .fs
+                .fs()
                 .create_file(&root_path.join("main.rs"), Default::default())
                 .await
                 .unwrap();
@@ -422,8 +422,8 @@ async fn apply_client_operation(
             );
 
             ensure_project_shared(&project, client, cx).await;
-            if !client.fs.paths(false).contains(&new_root_path) {
-                client.fs.create_dir(&new_root_path).await.unwrap();
+            if !client.fs().paths(false).contains(&new_root_path) {
+                client.fs().create_dir(&new_root_path).await.unwrap();
             }
             project
                 .update(cx, |project, cx| {
@@ -475,7 +475,7 @@ async fn apply_client_operation(
                     Some(room.update(cx, |room, cx| {
                         room.join_project(
                             project_id,
-                            client.language_registry.clone(),
+                            client.language_registry().clone(),
                             FakeFs::new(cx.background().clone()),
                             cx,
                         )
@@ -743,7 +743,7 @@ async fn apply_client_operation(
             content,
         } => {
             if !client
-                .fs
+                .fs()
                 .directories(false)
                 .contains(&path.parent().unwrap().to_owned())
             {
@@ -752,14 +752,14 @@ async fn apply_client_operation(
 
             if is_dir {
                 log::info!("{}: creating dir at {:?}", client.username, path);
-                client.fs.create_dir(&path).await.unwrap();
+                client.fs().create_dir(&path).await.unwrap();
             } else {
-                let exists = client.fs.metadata(&path).await?.is_some();
+                let exists = client.fs().metadata(&path).await?.is_some();
                 let verb = if exists { "updating" } else { "creating" };
                 log::info!("{}: {} file at {:?}", verb, client.username, path);
 
                 client
-                    .fs
+                    .fs()
                     .save(&path, &content.as_str().into(), fs::LineEnding::Unix)
                     .await
                     .unwrap();
@@ -771,12 +771,12 @@ async fn apply_client_operation(
                 repo_path,
                 contents,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
 
                 for (path, _) in contents.iter() {
-                    if !client.fs.files().contains(&repo_path.join(path)) {
+                    if !client.fs().files().contains(&repo_path.join(path)) {
                         return Err(TestError::Inapplicable);
                     }
                 }
@@ -793,16 +793,16 @@ async fn apply_client_operation(
                     .iter()
                     .map(|(path, contents)| (path.as_path(), contents.clone()))
                     .collect::<Vec<_>>();
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
-                client.fs.set_index_for_repo(&dot_git_dir, &contents);
+                client.fs().set_index_for_repo(&dot_git_dir, &contents);
             }
             GitOperation::WriteGitBranch {
                 repo_path,
                 new_branch,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
 
@@ -814,21 +814,21 @@ async fn apply_client_operation(
                 );
 
                 let dot_git_dir = repo_path.join(".git");
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
-                client.fs.set_branch_name(&dot_git_dir, new_branch);
+                client.fs().set_branch_name(&dot_git_dir, new_branch);
             }
             GitOperation::WriteGitStatuses {
                 repo_path,
                 statuses,
                 git_operation,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
                 for (path, _) in statuses.iter() {
-                    if !client.fs.files().contains(&repo_path.join(path)) {
+                    if !client.fs().files().contains(&repo_path.join(path)) {
                         return Err(TestError::Inapplicable);
                     }
                 }
@@ -847,16 +847,16 @@ async fn apply_client_operation(
                     .map(|(path, val)| (path.as_path(), val.clone()))
                     .collect::<Vec<_>>();
 
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
 
                 if git_operation {
                     client
-                        .fs
+                        .fs()
                         .set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice());
                 } else {
-                    client.fs.set_status_for_repo_via_working_copy_change(
+                    client.fs().set_status_for_repo_via_working_copy_change(
                         &dot_git_dir,
                         statuses.as_slice(),
                     );
@@ -1499,7 +1499,7 @@ impl TestPlan {
                         // Invite a contact to the current call
                         0..=70 => {
                             let available_contacts =
-                                client.user_store.read_with(cx, |user_store, _| {
+                                client.user_store().read_with(cx, |user_store, _| {
                                     user_store
                                         .contacts()
                                         .iter()
@@ -1596,7 +1596,7 @@ impl TestPlan {
                                 .choose(&mut self.rng)
                                 .cloned() else { continue };
                             let project_root_name = root_name_for_project(&project, cx);
-                            let mut paths = client.fs.paths(false);
+                            let mut paths = client.fs().paths(false);
                             paths.remove(0);
                             let new_root_path = if paths.is_empty() || self.rng.gen() {
                                 Path::new("/").join(&self.next_root_dir_name(user_id))
@@ -1776,7 +1776,7 @@ impl TestPlan {
                     let is_dir = self.rng.gen::<bool>();
                     let content;
                     let mut path;
-                    let dir_paths = client.fs.directories(false);
+                    let dir_paths = client.fs().directories(false);
 
                     if is_dir {
                         content = String::new();
@@ -1786,7 +1786,7 @@ impl TestPlan {
                         content = Alphanumeric.sample_string(&mut self.rng, 16);
 
                         // Create a new file or overwrite an existing file
-                        let file_paths = client.fs.files();
+                        let file_paths = client.fs().files();
                         if file_paths.is_empty() || self.rng.gen_bool(0.5) {
                             path = dir_paths.choose(&mut self.rng).unwrap().clone();
                             path.push(gen_file_name(&mut self.rng));
@@ -1812,7 +1812,7 @@ impl TestPlan {
             client: &TestClient,
         ) -> Vec<PathBuf> {
             let mut paths = client
-                .fs
+                .fs()
                 .files()
                 .into_iter()
                 .filter(|path| path.starts_with(repo_path))
@@ -1829,7 +1829,7 @@ impl TestPlan {
         }
 
         let repo_path = client
-            .fs
+            .fs()
             .directories(false)
             .choose(&mut self.rng)
             .unwrap()
@@ -1928,7 +1928,7 @@ async fn simulate_client(
             name: "the-fake-language-server",
             capabilities: lsp::LanguageServer::full_capabilities(),
             initializer: Some(Box::new({
-                let fs = client.fs.clone();
+                let fs = client.app_state.fs.clone();
                 move |fake_server: &mut FakeLanguageServer| {
                     fake_server.handle_request::<lsp::request::Completion, _, _>(
                         |_, _| async move {
@@ -1973,7 +1973,7 @@ async fn simulate_client(
                             let background = cx.background();
                             let mut rng = background.rng();
                             let count = rng.gen_range::<usize, _>(1..3);
-                            let files = fs.files();
+                            let files = fs.as_fake().files();
                             let files = (0..count)
                                 .map(|_| files.choose(&mut *rng).unwrap().clone())
                                 .collect::<Vec<_>>();
@@ -2023,7 +2023,7 @@ async fn simulate_client(
             ..Default::default()
         }))
         .await;
-    client.language_registry.add(Arc::new(language));
+    client.app_state.languages.add(Arc::new(language));
 
     while let Some(batch_id) = operation_rx.next().await {
         let Some((operation, applied)) = plan.lock().next_client_operation(&client, batch_id, &cx) else { break };

crates/collab_ui/src/collab_ui.rs 🔗

@@ -3,9 +3,9 @@ mod contact_notification;
 mod face_pile;
 mod incoming_call_notification;
 mod notifications;
+pub mod panel;
 mod project_shared_notification;
 mod sharing_status_indicator;
-pub mod panel;
 
 use call::{ActiveCall, Room};
 pub use collab_titlebar_item::CollabTitlebarItem;

crates/collab_ui/src/panel.rs 🔗

@@ -6,7 +6,7 @@ use anyhow::Result;
 use call::ActiveCall;
 use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore};
 use contact_finder::build_contact_finder;
-use context_menu::ContextMenu;
+use context_menu::{ContextMenu, ContextMenuItem};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{Cancel, Editor};
 use futures::StreamExt;
@@ -18,6 +18,7 @@ use gpui::{
         MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg,
     },
     geometry::{rect::RectF, vector::vec2f},
+    impl_actions,
     platform::{CursorStyle, MouseButton, PromptLevel},
     serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
     Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
@@ -36,8 +37,15 @@ use workspace::{
     Workspace,
 };
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RemoveChannel {
+    channel_id: u64,
+}
+
 actions!(collab_panel, [ToggleFocus]);
 
+impl_actions!(collab_panel, [RemoveChannel]);
+
 const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
 
 pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
@@ -49,6 +57,7 @@ pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
     cx.add_action(CollabPanel::select_next);
     cx.add_action(CollabPanel::select_prev);
     cx.add_action(CollabPanel::confirm);
+    cx.add_action(CollabPanel::remove_channel);
 }
 
 #[derive(Debug, Default)]
@@ -305,6 +314,8 @@ impl CollabPanel {
             let active_call = ActiveCall::global(cx);
             this.subscriptions
                 .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx)));
+            this.subscriptions
+                .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx)));
             this.subscriptions
                 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
 
@@ -1278,6 +1289,19 @@ impl CollabPanel {
         .on_click(MouseButton::Left, move |_, this, cx| {
             this.join_channel(channel_id, cx);
         })
+        .on_click(MouseButton::Right, move |e, this, cx| {
+            this.context_menu.update(cx, |context_menu, cx| {
+                context_menu.show(
+                    e.position,
+                    gpui::elements::AnchorCorner::BottomLeft,
+                    vec![ContextMenuItem::action(
+                        "Remove Channel",
+                        RemoveChannel { channel_id },
+                    )],
+                    cx,
+                );
+            });
+        })
         .into_any()
     }
 
@@ -1564,14 +1588,13 @@ impl CollabPanel {
                 }
             }
         } else if let Some((_editing_state, channel_name)) = self.take_editing_state(cx) {
-            dbg!(&channel_name);
             let create_channel = self.channel_store.update(cx, |channel_store, cx| {
                 channel_store.create_channel(&channel_name, None)
             });
 
             cx.foreground()
                 .spawn(async move {
-                    dbg!(create_channel.await).ok();
+                    create_channel.await.ok();
                 })
                 .detach();
         }
@@ -1600,6 +1623,36 @@ impl CollabPanel {
         }
     }
 
+    fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
+        let channel_id = action.channel_id;
+        let channel_store = self.channel_store.clone();
+        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
+            let prompt_message = format!(
+                "Are you sure you want to remove the channel \"{}\"?",
+                channel.name
+            );
+            let mut answer =
+                cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+            let window_id = cx.window_id();
+            cx.spawn(|_, mut cx| async move {
+                if answer.next().await == Some(0) {
+                    if let Err(e) = channel_store
+                        .update(&mut cx, |channels, cx| channels.remove_channel(channel_id))
+                        .await
+                    {
+                        cx.prompt(
+                            window_id,
+                            PromptLevel::Info,
+                            &format!("Failed to remove channel: {}", e),
+                            &["Ok"],
+                        );
+                    }
+                }
+            })
+            .detach();
+        }
+    }
+
     fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
         let user_store = self.user_store.clone();
         let prompt_message = format!(

crates/collab_ui/src/panel/channel_modal.rs 🔗

@@ -1,5 +1,5 @@
 use editor::Editor;
-use gpui::{elements::*, AnyViewHandle, Entity, View, ViewContext, ViewHandle, AppContext};
+use gpui::{elements::*, AnyViewHandle, AppContext, Entity, View, ViewContext, ViewHandle};
 use menu::Cancel;
 use workspace::{item::ItemHandle, Modal};
 
@@ -62,12 +62,10 @@ impl View for ChannelModal {
                 .constrained()
                 .with_max_width(540.)
                 .with_max_height(420.)
-
         })
         .on_click(gpui::platform::MouseButton::Left, |_, _, _| {}) // Capture click and down events
-        .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| {
-            v.dismiss(cx)
-        }).into_any_named("channel modal")
+        .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| v.dismiss(cx))
+        .into_any_named("channel modal")
     }
 
     fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {

crates/rpc/proto/zed.proto 🔗

@@ -137,6 +137,7 @@ message Envelope {
         RespondToChannelInvite respond_to_channel_invite = 123;
         UpdateChannels update_channels = 124;
         JoinChannel join_channel = 125;
+        RemoveChannel remove_channel = 126;
     }
 }
 
@@ -875,6 +876,11 @@ message JoinChannel {
     uint64 channel_id = 1;
 }
 
+message RemoveChannel {
+    uint64 channel_id = 1;
+}
+
+
 message CreateChannel {
     string name = 1;
     optional uint64 parent_id = 2;

crates/rpc/src/proto.rs 🔗

@@ -231,6 +231,7 @@ messages!(
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
+    (RemoveChannel, Foreground),
     (UpdateChannels, Foreground),
     (UpdateDiagnosticSummary, Foreground),
     (UpdateFollowers, Foreground),
@@ -296,6 +297,7 @@ request_messages!(
     (RespondToContactRequest, Ack),
     (RespondToChannelInvite, Ack),
     (JoinChannel, JoinRoomResponse),
+    (RemoveChannel, Ack),
     (RenameProjectEntry, ProjectEntryResponse),
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),

crates/workspace/src/workspace.rs 🔗

@@ -3412,6 +3412,7 @@ impl Workspace {
     pub fn test_new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
         let client = project.read(cx).client();
         let user_store = project.read(cx).user_store();
+
         let channel_store =
             cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let app_state = Arc::new(AppState {