Start work on displaying channels and invites in collab panel

Max Brunsfeld created

Change summary

crates/client/src/channel_store.rs       | 125 ++++++++------
crates/client/src/channel_store_tests.rs |  95 +++++++++++
crates/client/src/client.rs              |   3 
crates/collab/src/tests.rs               |   1 
crates/collab/src/tests/channel_tests.rs |  15 +
crates/collab_ui/src/panel.rs            | 215 +++++++++++++++++++++++++
crates/workspace/src/workspace.rs        |  19 +
crates/zed/src/main.rs                   |   7 
8 files changed, 411 insertions(+), 69 deletions(-)

Detailed changes

crates/client/src/channel_store.rs 🔗

@@ -6,18 +6,19 @@ use rpc::{proto, TypedEnvelope};
 use std::sync::Arc;
 
 pub struct ChannelStore {
-    channels: Vec<Channel>,
-    channel_invitations: Vec<Channel>,
+    channels: Vec<Arc<Channel>>,
+    channel_invitations: Vec<Arc<Channel>>,
     client: Arc<Client>,
     user_store: ModelHandle<UserStore>,
     _rpc_subscription: Subscription,
 }
 
-#[derive(Debug, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
 pub struct Channel {
     pub id: u64,
     pub name: String,
     pub parent_id: Option<u64>,
+    pub depth: usize,
 }
 
 impl Entity for ChannelStore {
@@ -41,11 +42,11 @@ impl ChannelStore {
         }
     }
 
-    pub fn channels(&self) -> &[Channel] {
+    pub fn channels(&self) -> &[Arc<Channel>] {
         &self.channels
     }
 
-    pub fn channel_invitations(&self) -> &[Channel] {
+    pub fn channel_invitations(&self) -> &[Arc<Channel>] {
         &self.channel_invitations
     }
 
@@ -97,6 +98,10 @@ impl ChannelStore {
         }
     }
 
+    pub fn is_channel_invite_pending(&self, channel: &Arc<Channel>) -> bool {
+        false
+    }
+
     pub fn remove_member(
         &self,
         channel_id: u64,
@@ -124,66 +129,74 @@ impl ChannelStore {
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        let payload = message.payload;
         this.update(&mut cx, |this, cx| {
-            this.channels
-                .retain(|channel| !payload.remove_channels.contains(&channel.id));
-            this.channel_invitations
-                .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id));
-
-            for channel in payload.channel_invitations {
-                if let Some(existing_channel) = this
-                    .channel_invitations
-                    .iter_mut()
-                    .find(|c| c.id == channel.id)
-                {
-                    existing_channel.name = channel.name;
-                    continue;
-                }
+            this.update_channels(message.payload, cx);
+        });
+        Ok(())
+    }
 
-                this.channel_invitations.insert(
-                    0,
-                    Channel {
-                        id: channel.id,
-                        name: channel.name,
-                        parent_id: None,
-                    },
-                );
+    pub(crate) fn update_channels(
+        &mut self,
+        payload: proto::UpdateChannels,
+        cx: &mut ModelContext<ChannelStore>,
+    ) {
+        self.channels
+            .retain(|channel| !payload.remove_channels.contains(&channel.id));
+        self.channel_invitations
+            .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id));
+
+        for channel in payload.channel_invitations {
+            if let Some(existing_channel) = self
+                .channel_invitations
+                .iter_mut()
+                .find(|c| c.id == channel.id)
+            {
+                Arc::make_mut(existing_channel).name = channel.name;
+                continue;
             }
 
-            for channel in payload.channels {
-                if let Some(existing_channel) =
-                    this.channels.iter_mut().find(|c| c.id == channel.id)
-                {
-                    existing_channel.name = channel.name;
-                    continue;
-                }
+            self.channel_invitations.insert(
+                0,
+                Arc::new(Channel {
+                    id: channel.id,
+                    name: channel.name,
+                    parent_id: None,
+                    depth: 0,
+                }),
+            );
+        }
+
+        for channel in payload.channels {
+            if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) {
+                Arc::make_mut(existing_channel).name = channel.name;
+                continue;
+            }
 
-                if let Some(parent_id) = channel.parent_id {
-                    if let Some(ix) = this.channels.iter().position(|c| c.id == parent_id) {
-                        this.channels.insert(
-                            ix + 1,
-                            Channel {
-                                id: channel.id,
-                                name: channel.name,
-                                parent_id: Some(parent_id),
-                            },
-                        );
-                    }
-                } else {
-                    this.channels.insert(
-                        0,
-                        Channel {
+            if let Some(parent_id) = channel.parent_id {
+                if let Some(ix) = self.channels.iter().position(|c| c.id == parent_id) {
+                    let depth = self.channels[ix].depth + 1;
+                    self.channels.insert(
+                        ix + 1,
+                        Arc::new(Channel {
                             id: channel.id,
                             name: channel.name,
-                            parent_id: None,
-                        },
+                            parent_id: Some(parent_id),
+                            depth,
+                        }),
                     );
                 }
+            } else {
+                self.channels.insert(
+                    0,
+                    Arc::new(Channel {
+                        id: channel.id,
+                        name: channel.name,
+                        parent_id: None,
+                        depth: 0,
+                    }),
+                );
             }
-            cx.notify();
-        });
-
-        Ok(())
+        }
+        cx.notify();
     }
 }

crates/client/src/channel_store_tests.rs 🔗

@@ -0,0 +1,95 @@
+use util::http::FakeHttpClient;
+
+use super::*;
+
+#[gpui::test]
+fn test_update_channels(cx: &mut AppContext) {
+    let http = FakeHttpClient::with_404_response();
+    let client = Client::new(http.clone(), cx);
+    let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+
+    let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            channels: vec![
+                proto::Channel {
+                    id: 1,
+                    name: "b".to_string(),
+                    parent_id: None,
+                },
+                proto::Channel {
+                    id: 2,
+                    name: "a".to_string(),
+                    parent_id: None,
+                },
+            ],
+            ..Default::default()
+        },
+        cx,
+    );
+    assert_channels(
+        &channel_store,
+        &[
+            //
+            (0, "a"),
+            (0, "b"),
+        ],
+        cx,
+    );
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            channels: vec![
+                proto::Channel {
+                    id: 3,
+                    name: "x".to_string(),
+                    parent_id: Some(1),
+                },
+                proto::Channel {
+                    id: 4,
+                    name: "y".to_string(),
+                    parent_id: Some(2),
+                },
+            ],
+            ..Default::default()
+        },
+        cx,
+    );
+    assert_channels(
+        &channel_store,
+        &[
+            //
+            (0, "a"),
+            (1, "y"),
+            (0, "b"),
+            (1, "x"),
+        ],
+        cx,
+    );
+}
+
+fn update_channels(
+    channel_store: &ModelHandle<ChannelStore>,
+    message: proto::UpdateChannels,
+    cx: &mut AppContext,
+) {
+    channel_store.update(cx, |store, cx| store.update_channels(message, cx));
+}
+
+fn assert_channels(
+    channel_store: &ModelHandle<ChannelStore>,
+    expected_channels: &[(usize, &str)],
+    cx: &AppContext,
+) {
+    channel_store.read_with(cx, |store, _| {
+        let actual = store
+            .channels()
+            .iter()
+            .map(|c| (c.depth, c.name.as_str()))
+            .collect::<Vec<_>>();
+        assert_eq!(actual, expected_channels);
+    });
+}

crates/client/src/client.rs 🔗

@@ -1,6 +1,9 @@
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+#[cfg(test)]
+mod channel_store_tests;
+
 pub mod channel_store;
 pub mod telemetry;
 pub mod user;

crates/collab/src/tests.rs 🔗

@@ -193,6 +193,7 @@ impl TestServer {
         let app_state = Arc::new(workspace::AppState {
             client: client.clone(),
             user_store: user_store.clone(),
+            channel_store: channel_store.clone(),
             languages: Arc::new(LanguageRegistry::test()),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),

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

@@ -29,11 +29,12 @@ async fn test_basic_channels(
     client_a.channel_store.read_with(cx_a, |channels, _| {
         assert_eq!(
             channels.channels(),
-            &[Channel {
+            &[Arc::new(Channel {
                 id: channel_a_id,
                 name: "channel-a".to_string(),
                 parent_id: None,
-            }]
+                depth: 0,
+            })]
         )
     });
 
@@ -56,11 +57,12 @@ async fn test_basic_channels(
     client_b.channel_store.read_with(cx_b, |channels, _| {
         assert_eq!(
             channels.channel_invitations(),
-            &[Channel {
+            &[Arc::new(Channel {
                 id: channel_a_id,
                 name: "channel-a".to_string(),
                 parent_id: None,
-            }]
+                depth: 0,
+            })]
         )
     });
 
@@ -76,11 +78,12 @@ async fn test_basic_channels(
         assert_eq!(channels.channel_invitations(), &[]);
         assert_eq!(
             channels.channels(),
-            &[Channel {
+            &[Arc::new(Channel {
                 id: channel_a_id,
                 name: "channel-a".to_string(),
                 parent_id: None,
-            }]
+                depth: 0,
+            })]
         )
     });
 }

crates/collab_ui/src/panel.rs 🔗

@@ -4,7 +4,7 @@ mod panel_settings;
 
 use anyhow::Result;
 use call::ActiveCall;
-use client::{proto::PeerId, Client, Contact, User, UserStore};
+use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore};
 use contact_finder::build_contact_finder;
 use context_menu::ContextMenu;
 use db::kvp::KEY_VALUE_STORE;
@@ -62,6 +62,7 @@ pub struct CollabPanel {
     entries: Vec<ContactEntry>,
     selection: Option<usize>,
     user_store: ModelHandle<UserStore>,
+    channel_store: ModelHandle<ChannelStore>,
     project: ModelHandle<Project>,
     match_candidates: Vec<StringMatchCandidate>,
     list_state: ListState<Self>,
@@ -109,8 +110,10 @@ enum ContactEntry {
         peer_id: PeerId,
         is_last: bool,
     },
+    ChannelInvite(Arc<Channel>),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
+    Channel(Arc<Channel>),
     Contact {
         contact: Arc<Contact>,
         calling: bool,
@@ -204,6 +207,16 @@ impl CollabPanel {
                                 cx,
                             )
                         }
+                        ContactEntry::Channel(channel) => {
+                            Self::render_channel(&*channel, &theme.collab_panel, is_selected, cx)
+                        }
+                        ContactEntry::ChannelInvite(channel) => Self::render_channel_invite(
+                            channel.clone(),
+                            this.channel_store.clone(),
+                            &theme.collab_panel,
+                            is_selected,
+                            cx,
+                        ),
                         ContactEntry::IncomingRequest(user) => Self::render_contact_request(
                             user.clone(),
                             this.user_store.clone(),
@@ -241,6 +254,7 @@ impl CollabPanel {
                 entries: Vec::default(),
                 selection: None,
                 user_store: workspace.user_store().clone(),
+                channel_store: workspace.app_state().channel_store.clone(),
                 project: workspace.project().clone(),
                 subscriptions: Vec::default(),
                 match_candidates: Vec::default(),
@@ -320,6 +334,7 @@ impl CollabPanel {
     }
 
     fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
         let user_store = self.user_store.read(cx);
         let query = self.filter_editor.read(cx).text(cx);
         let executor = cx.background().clone();
@@ -445,10 +460,65 @@ impl CollabPanel {
         self.entries
             .push(ContactEntry::Header(Section::Channels, 0));
 
+        let channels = channel_store.channels();
+        if !channels.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    channels
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, channel)| StringMatchCandidate {
+                            id: ix,
+                            string: channel.name.clone(),
+                            char_bag: channel.name.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            self.entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ContactEntry::Channel(channels[mat.candidate_id].clone())),
+            );
+        }
+
         self.entries
             .push(ContactEntry::Header(Section::Contacts, 0));
 
         let mut request_entries = Vec::new();
+        let channel_invites = channel_store.channel_invitations();
+        if !channel_invites.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
+                    StringMatchCandidate {
+                        id: ix,
+                        string: channel.name.clone(),
+                        char_bag: channel.name.chars().collect(),
+                    }
+                }));
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches.iter().map(|mat| {
+                    ContactEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
+                }),
+            );
+        }
+
         let incoming = user_store.incoming_contact_requests();
         if !incoming.is_empty() {
             self.match_candidates.clear();
@@ -1112,6 +1182,121 @@ impl CollabPanel {
         event_handler.into_any()
     }
 
+    fn render_channel(
+        channel: &Channel,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let channel_id = channel.id;
+        MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, cx| {
+            Flex::row()
+                .with_child({
+                    Svg::new("icons/hash")
+                        // .with_style(theme.contact_avatar)
+                        .aligned()
+                        .left()
+                })
+                .with_child(
+                    Label::new(channel.name.clone(), theme.contact_username.text.clone())
+                        .contained()
+                        .with_style(theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.join_channel(channel_id, cx);
+        })
+        .into_any()
+    }
+
+    fn render_channel_invite(
+        channel: Arc<Channel>,
+        user_store: ModelHandle<ChannelStore>,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Decline {}
+        enum Accept {}
+
+        let channel_id = channel.id;
+        let is_invite_pending = user_store.read(cx).is_channel_invite_pending(&channel);
+        let button_spacing = theme.contact_button_spacing;
+
+        Flex::row()
+            .with_child({
+                Svg::new("icons/hash")
+                    // .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            })
+            .with_child(
+                Label::new(channel.name.clone(), theme.contact_username.text.clone())
+                    .contained()
+                    .with_style(theme.contact_username.container)
+                    .aligned()
+                    .left()
+                    .flex(1., true),
+            )
+            .with_child(
+                MouseEventHandler::<Decline, Self>::new(
+                    channel.id as usize,
+                    cx,
+                    |mouse_state, _| {
+                        let button_style = if is_invite_pending {
+                            &theme.disabled_button
+                        } else {
+                            theme.contact_button.style_for(mouse_state)
+                        };
+                        render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
+                    },
+                )
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, false, cx);
+                })
+                .contained()
+                .with_margin_right(button_spacing),
+            )
+            .with_child(
+                MouseEventHandler::<Accept, Self>::new(
+                    channel.id as usize,
+                    cx,
+                    |mouse_state, _| {
+                        let button_style = if is_invite_pending {
+                            &theme.disabled_button
+                        } else {
+                            theme.contact_button.style_for(mouse_state)
+                        };
+                        render_icon_button(button_style, "icons/check_8.svg")
+                            .aligned()
+                            .flex_float()
+                    },
+                )
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, true, cx);
+                }),
+            )
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .into_any()
+    }
+
     fn render_contact_request(
         user: Arc<User>,
         user_store: ModelHandle<UserStore>,
@@ -1384,6 +1569,18 @@ impl CollabPanel {
             .detach();
     }
 
+    fn respond_to_channel_invite(
+        &mut self,
+        channel_id: u64,
+        accept: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let respond = self.channel_store.update(cx, |store, _| {
+            store.respond_to_channel_invite(channel_id, accept)
+        });
+        cx.foreground().spawn(respond).detach();
+    }
+
     fn call(
         &mut self,
         recipient_user_id: u64,
@@ -1396,6 +1593,12 @@ impl CollabPanel {
             })
             .detach_and_log_err(cx);
     }
+
+    fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.join_channel(channel, cx))
+            .detach_and_log_err(cx);
+    }
 }
 
 impl View for CollabPanel {
@@ -1557,6 +1760,16 @@ impl PartialEq for ContactEntry {
                     return peer_id_1 == peer_id_2;
                 }
             }
+            ContactEntry::Channel(channel_1) => {
+                if let ContactEntry::Channel(channel_2) = other {
+                    return channel_1.id == channel_2.id;
+                }
+            }
+            ContactEntry::ChannelInvite(channel_1) => {
+                if let ContactEntry::ChannelInvite(channel_2) = other {
+                    return channel_1.id == channel_2.id;
+                }
+            }
             ContactEntry::IncomingRequest(user_1) => {
                 if let ContactEntry::IncomingRequest(user_2) = other {
                     return user_1.id == user_2.id;

crates/workspace/src/workspace.rs 🔗

@@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result};
 use call::ActiveCall;
 use client::{
     proto::{self, PeerId},
-    Client, TypedEnvelope, UserStore,
+    ChannelStore, Client, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use drag_and_drop::DragAndDrop;
@@ -400,8 +400,9 @@ pub fn register_deserializable_item<I: Item>(cx: &mut AppContext) {
 
 pub struct AppState {
     pub languages: Arc<LanguageRegistry>,
-    pub client: Arc<client::Client>,
-    pub user_store: ModelHandle<client::UserStore>,
+    pub client: Arc<Client>,
+    pub user_store: ModelHandle<UserStore>,
+    pub channel_store: ModelHandle<ChannelStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options:
         fn(Option<WindowBounds>, Option<uuid::Uuid>, &dyn Platform) -> WindowOptions<'static>,
@@ -424,6 +425,8 @@ impl AppState {
         let http_client = util::http::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone(), cx);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
 
         theme::init((), cx);
         client::init(&client, cx);
@@ -434,6 +437,7 @@ impl AppState {
             fs,
             languages,
             user_store,
+            channel_store,
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             build_window_options: |_, _, _| Default::default(),
             background_actions: || &[],
@@ -3406,10 +3410,15 @@ impl Workspace {
 
     #[cfg(any(test, feature = "test-support"))]
     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 {
             languages: project.read(cx).languages().clone(),
-            client: project.read(cx).client(),
-            user_store: project.read(cx).user_store(),
+            client,
+            user_store,
+            channel_store,
             fs: project.read(cx).fs().clone(),
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),

crates/zed/src/main.rs 🔗

@@ -7,7 +7,9 @@ use cli::{
     ipc::{self, IpcSender},
     CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
 };
-use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{
+    self, ChannelStore, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
+};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{scroll::autoscroll::Autoscroll, Editor};
 use futures::{
@@ -140,6 +142,8 @@ fn main() {
 
         languages::init(languages.clone(), node_runtime.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
 
         cx.set_global(client.clone());
 
@@ -181,6 +185,7 @@ fn main() {
             languages,
             client: client.clone(),
             user_store,
+            channel_store,
             fs,
             build_window_options,
             initialize_workspace,