WIP: Add channel creation to panel UI

Mikayla Maki created

Change summary

crates/client/src/channel_store.rs |   1 
crates/collab/src/db.rs            |  38 +++++++
crates/collab/src/db/tests.rs      |  90 +++++++++++++++++
crates/collab/src/rpc.rs           |  34 ++++++
crates/collab_ui/src/panel.rs      | 162 ++++++++++++++++++++++---------
script/zed-with-local-servers      |   2 
6 files changed, 278 insertions(+), 49 deletions(-)

Detailed changes

crates/client/src/channel_store.rs 🔗

@@ -33,6 +33,7 @@ impl ChannelStore {
     ) -> Self {
         let rpc_subscription =
             client.add_message_handler(cx.handle(), Self::handle_update_channels);
+
         Self {
             channels: vec![],
             channel_invitations: vec![],

crates/collab/src/db.rs 🔗

@@ -3214,6 +3214,44 @@ impl Database {
         .await
     }
 
+    pub async fn get_channel_invites(&self, user_id: UserId) -> Result<Vec<Channel>> {
+        self.transaction(|tx| async move {
+            let tx = tx;
+
+            let channel_invites = channel_member::Entity::find()
+                .filter(
+                    channel_member::Column::UserId
+                        .eq(user_id)
+                        .and(channel_member::Column::Accepted.eq(false)),
+                )
+                .all(&*tx)
+                .await?;
+
+            let channels = channel::Entity::find()
+                .filter(
+                    channel::Column::Id.is_in(
+                        channel_invites
+                            .into_iter()
+                            .map(|channel_member| channel_member.channel_id),
+                    ),
+                )
+                .all(&*tx)
+                .await?;
+
+            let channels = channels
+                .into_iter()
+                .map(|channel| Channel {
+                    id: channel.id,
+                    name: channel.name,
+                    parent_id: None,
+                })
+                .collect();
+
+            Ok(channels)
+        })
+        .await
+    }
+
     pub async fn get_channels(&self, user_id: UserId) -> Result<Vec<Channel>> {
         self.transaction(|tx| async move {
             let tx = tx;

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

@@ -1023,6 +1023,96 @@ test_both_dbs!(
     }
 );
 
+test_both_dbs!(
+    test_channel_invites_postgres,
+    test_channel_invites_sqlite,
+    db,
+    {
+        let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
+        let user_1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user1".into(),
+                    github_user_id: 5,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let user_2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user2".into(),
+                    github_user_id: 6,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let user_3 = db
+            .create_user(
+                "user3@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user3".into(),
+                    github_user_id: 7,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let channel_1_1 = db
+            .create_root_channel("channel_1", "1", user_1)
+            .await
+            .unwrap();
+
+        let channel_1_2 = db
+            .create_root_channel("channel_2", "2", user_1)
+            .await
+            .unwrap();
+
+        db.invite_channel_member(channel_1_1, user_2, user_1, false)
+            .await
+            .unwrap();
+        db.invite_channel_member(channel_1_2, user_2, user_1, false)
+            .await
+            .unwrap();
+        db.invite_channel_member(channel_1_1, user_3, user_1, false)
+            .await
+            .unwrap();
+
+        let user_2_invites = db
+            .get_channel_invites(user_2) // -> [channel_1_1, channel_1_2]
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|channel| channel.id)
+            .collect::<Vec<_>>();
+
+        assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]);
+
+        let user_3_invites = db
+            .get_channel_invites(user_3) // -> [channel_1_1]
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|channel| channel.id)
+            .collect::<Vec<_>>();
+
+        assert_eq!(user_3_invites, &[channel_1_1])
+    }
+);
+
 #[gpui::test]
 async fn test_multiple_signup_overwrite() {
     let test_db = TestDb::postgres(build_background_executor());

crates/collab/src/rpc.rs 🔗

@@ -516,15 +516,19 @@ impl Server {
                 this.app_state.db.set_user_connected_once(user_id, true).await?;
             }
 
-            let (contacts, invite_code) = future::try_join(
+            let (contacts, invite_code, channels, channel_invites) = future::try_join4(
                 this.app_state.db.get_contacts(user_id),
-                this.app_state.db.get_invite_code_for_user(user_id)
+                this.app_state.db.get_invite_code_for_user(user_id),
+                this.app_state.db.get_channels(user_id),
+                this.app_state.db.get_channel_invites(user_id)
             ).await?;
 
             {
                 let mut pool = this.connection_pool.lock();
                 pool.add_connection(connection_id, user_id, user.admin);
                 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 {
@@ -2097,6 +2101,7 @@ 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));
 
@@ -2307,6 +2312,31 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage {
     }
 }
 
+fn build_initial_channels_update(
+    channels: Vec<db::Channel>,
+    channel_invites: Vec<db::Channel>,
+) -> proto::UpdateChannels {
+    let mut update = proto::UpdateChannels::default();
+
+    for channel in channels {
+        update.channels.push(proto::Channel {
+            id: channel.id.to_proto(),
+            name: channel.name,
+            parent_id: None,
+        });
+    }
+
+    for channel in channel_invites {
+        update.channel_invitations.push(proto::Channel {
+            id: channel.id.to_proto(),
+            name: channel.name,
+            parent_id: None,
+        });
+    }
+
+    update
+}
+
 fn build_initial_contacts_update(
     contacts: Vec<db::Contact>,
     pool: &ConnectionPool,

crates/collab_ui/src/panel.rs 🔗

@@ -32,11 +32,10 @@ use theme::IconButton;
 use util::{ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel},
+    item::ItemHandle,
     Workspace,
 };
 
-use self::channel_modal::ChannelModal;
-
 actions!(collab_panel, [ToggleFocus]);
 
 const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
@@ -52,6 +51,11 @@ pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
     cx.add_action(CollabPanel::confirm);
 }
 
+#[derive(Debug, Default)]
+pub struct ChannelEditingState {
+    root_channel: bool,
+}
+
 pub struct CollabPanel {
     width: Option<f32>,
     fs: Arc<dyn Fs>,
@@ -59,6 +63,8 @@ pub struct CollabPanel {
     pending_serialization: Task<Option<()>>,
     context_menu: ViewHandle<ContextMenu>,
     filter_editor: ViewHandle<Editor>,
+    channel_name_editor: ViewHandle<Editor>,
+    channel_editing_state: Option<ChannelEditingState>,
     entries: Vec<ContactEntry>,
     selection: Option<usize>,
     user_store: ModelHandle<UserStore>,
@@ -93,7 +99,7 @@ enum Section {
     Offline,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 enum ContactEntry {
     Header(Section, usize),
     CallParticipant {
@@ -157,6 +163,23 @@ impl CollabPanel {
             })
             .detach();
 
+            let channel_name_editor = cx.add_view(|cx| {
+                Editor::single_line(
+                    Some(Arc::new(|theme| {
+                        theme.collab_panel.user_query_editor.clone()
+                    })),
+                    cx,
+                )
+            });
+
+            cx.subscribe(&channel_name_editor, |this, _, event, cx| {
+                if let editor::Event::Blurred = event {
+                    this.take_editing_state(cx);
+                    cx.notify();
+                }
+            })
+            .detach();
+
             let list_state =
                 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
                     let theme = theme::current(cx).clone();
@@ -166,7 +189,7 @@ impl CollabPanel {
                     match &this.entries[ix] {
                         ContactEntry::Header(section, depth) => {
                             let is_collapsed = this.collapsed_sections.contains(section);
-                            Self::render_header(
+                            this.render_header(
                                 *section,
                                 &theme,
                                 *depth,
@@ -250,8 +273,10 @@ impl CollabPanel {
                 fs: workspace.app_state().fs.clone(),
                 pending_serialization: Task::ready(None),
                 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
+                channel_name_editor,
                 filter_editor,
                 entries: Vec::default(),
+                channel_editing_state: None,
                 selection: None,
                 user_store: workspace.user_store().clone(),
                 channel_store: workspace.app_state().channel_store.clone(),
@@ -333,6 +358,13 @@ impl CollabPanel {
         );
     }
 
+    fn is_editing_root_channel(&self) -> bool {
+        self.channel_editing_state
+            .as_ref()
+            .map(|state| state.root_channel)
+            .unwrap_or(false)
+    }
+
     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);
@@ -944,7 +976,23 @@ impl CollabPanel {
         .into_any()
     }
 
+    fn take_editing_state(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(ChannelEditingState, String)> {
+        let result = self
+            .channel_editing_state
+            .take()
+            .map(|state| (state, self.channel_name_editor.read(cx).text(cx)));
+
+        self.channel_name_editor
+            .update(cx, |editor, cx| editor.set_text("", cx));
+
+        result
+    }
+
     fn render_header(
+        &self,
         section: Section,
         theme: &theme::Theme,
         depth: usize,
@@ -1014,7 +1062,13 @@ impl CollabPanel {
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, |_, this, cx| {
-                    this.toggle_channel_finder(cx);
+                    if this.channel_editing_state.is_none() {
+                        this.channel_editing_state =
+                            Some(ChannelEditingState { root_channel: true });
+                    }
+
+                    cx.focus(this.channel_name_editor.as_any());
+                    cx.notify();
                 })
                 .with_tooltip::<AddChannel>(
                     0,
@@ -1027,6 +1081,13 @@ impl CollabPanel {
             _ => None,
         };
 
+        let addition = match section {
+            Section::Channels if self.is_editing_root_channel() => {
+                Some(ChildView::new(self.channel_name_editor.as_any(), cx))
+            }
+            _ => None,
+        };
+
         let can_collapse = depth > 0;
         let icon_size = (&theme.collab_panel).section_icon_size;
         MouseEventHandler::<Header, Self>::new(section as usize, cx, |state, _| {
@@ -1040,40 +1101,44 @@ impl CollabPanel {
                 &theme.collab_panel.header_row
             };
 
-            Flex::row()
-                .with_children(if can_collapse {
-                    Some(
-                        Svg::new(if is_collapsed {
-                            "icons/chevron_right_8.svg"
+            Flex::column()
+                .with_child(
+                    Flex::row()
+                        .with_children(if can_collapse {
+                            Some(
+                                Svg::new(if is_collapsed {
+                                    "icons/chevron_right_8.svg"
+                                } else {
+                                    "icons/chevron_down_8.svg"
+                                })
+                                .with_color(header_style.text.color)
+                                .constrained()
+                                .with_max_width(icon_size)
+                                .with_max_height(icon_size)
+                                .aligned()
+                                .constrained()
+                                .with_width(icon_size)
+                                .contained()
+                                .with_margin_right(
+                                    theme.collab_panel.contact_username.container.margin.left,
+                                ),
+                            )
                         } else {
-                            "icons/chevron_down_8.svg"
+                            None
                         })
-                        .with_color(header_style.text.color)
-                        .constrained()
-                        .with_max_width(icon_size)
-                        .with_max_height(icon_size)
-                        .aligned()
+                        .with_child(
+                            Label::new(text, header_style.text.clone())
+                                .aligned()
+                                .left()
+                                .flex(1., true),
+                        )
+                        .with_children(button.map(|button| button.aligned().right()))
                         .constrained()
-                        .with_width(icon_size)
+                        .with_height(theme.collab_panel.row_height)
                         .contained()
-                        .with_margin_right(
-                            theme.collab_panel.contact_username.container.margin.left,
-                        ),
-                    )
-                } else {
-                    None
-                })
-                .with_child(
-                    Label::new(text, header_style.text.clone())
-                        .aligned()
-                        .left()
-                        .flex(1., true),
+                        .with_style(header_style.container),
                 )
-                .with_children(button.map(|button| button.aligned().right()))
-                .constrained()
-                .with_height(theme.collab_panel.row_height)
-                .contained()
-                .with_style(header_style.container)
+                .with_children(addition)
         })
         .with_cursor_style(CursorStyle::PointingHand)
         .on_click(MouseButton::Left, move |_, this, cx| {
@@ -1189,7 +1254,7 @@ impl CollabPanel {
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let channel_id = channel.id;
-        MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, cx| {
+        MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, _cx| {
             Flex::row()
                 .with_child({
                     Svg::new("icons/hash")
@@ -1218,7 +1283,7 @@ impl CollabPanel {
 
     fn render_channel_invite(
         channel: Arc<Channel>,
-        user_store: ModelHandle<ChannelStore>,
+        channel_store: ModelHandle<ChannelStore>,
         theme: &theme::CollabPanel,
         is_selected: bool,
         cx: &mut ViewContext<Self>,
@@ -1227,7 +1292,7 @@ impl CollabPanel {
         enum Accept {}
 
         let channel_id = channel.id;
-        let is_invite_pending = user_store.read(cx).is_channel_invite_pending(&channel);
+        let is_invite_pending = channel_store.read(cx).is_channel_invite_pending(&channel);
         let button_spacing = theme.contact_button_spacing;
 
         Flex::row()
@@ -1401,7 +1466,7 @@ impl CollabPanel {
     }
 
     fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        let did_clear = self.filter_editor.update(cx, |editor, cx| {
+        let mut did_clear = self.filter_editor.update(cx, |editor, cx| {
             if editor.buffer().read(cx).len(cx) > 0 {
                 editor.set_text("", cx);
                 true
@@ -1410,6 +1475,8 @@ impl CollabPanel {
             }
         });
 
+        did_clear |= self.take_editing_state(cx).is_some();
+
         if !did_clear {
             cx.emit(Event::Dismissed);
         }
@@ -1496,6 +1563,17 @@ 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();
+                })
+                .detach();
         }
     }
 
@@ -1522,14 +1600,6 @@ impl CollabPanel {
         }
     }
 
-    fn toggle_channel_finder(&mut self, cx: &mut ViewContext<Self>) {
-        if let Some(workspace) = self.workspace.upgrade(cx) {
-            workspace.update(cx, |workspace, cx| {
-                workspace.toggle_modal(cx, |_, cx| cx.add_view(|cx| ChannelModal::new(cx)));
-            });
-        }
-    }
-
     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!(

script/zed-with-local-servers 🔗

@@ -1,3 +1,3 @@
 #!/bin/bash
 
-ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@
+ZED_ADMIN_API_TOKEN=secret ZED_IMPERSONATE=as-cii ZED_SERVER_URL=http://localhost:8080 cargo run $@