From 7954b02819bcdfb3573624394f53979f04d0879d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 31 Jul 2023 18:00:14 -0700 Subject: [PATCH] Start work on displaying channels and invites in collab panel --- crates/client/src/channel_store.rs | 127 +++++++------ 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, 412 insertions(+), 70 deletions(-) create mode 100644 crates/client/src/channel_store_tests.rs diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index e78dafe4e8..678e712c7d 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -6,18 +6,19 @@ use rpc::{proto, TypedEnvelope}; use std::sync::Arc; pub struct ChannelStore { - channels: Vec, - channel_invitations: Vec, + channels: Vec>, + channel_invitations: Vec>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Channel { pub id: u64, pub name: String, pub parent_id: Option, + pub depth: usize, } impl Entity for ChannelStore { @@ -41,11 +42,11 @@ impl ChannelStore { } } - pub fn channels(&self) -> &[Channel] { + pub fn channels(&self) -> &[Arc] { &self.channels } - pub fn channel_invitations(&self) -> &[Channel] { + pub fn channel_invitations(&self) -> &[Arc] { &self.channel_invitations } @@ -97,6 +98,10 @@ impl ChannelStore { } } + pub fn is_channel_invite_pending(&self, channel: &Arc) -> bool { + false + } + pub fn remove_member( &self, channel_id: u64, @@ -124,66 +129,74 @@ impl ChannelStore { _: Arc, 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)); + this.update_channels(message.payload, cx); + }); + Ok(()) + } - 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; + pub(crate) fn update_channels( + &mut self, + payload: proto::UpdateChannels, + cx: &mut ModelContext, + ) { + 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; + } + + 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) = 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: Some(parent_id), + depth, + }), + ); } - - this.channel_invitations.insert( + } else { + self.channels.insert( 0, - Channel { + Arc::new(Channel { id: channel.id, name: channel.name, parent_id: None, - }, + depth: 0, + }), ); } - - 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; - } - - 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 { - id: channel.id, - name: channel.name, - parent_id: None, - }, - ); - } - } - cx.notify(); - }); - - Ok(()) + } + cx.notify(); } } diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs new file mode 100644 index 0000000000..0d4ec6ce35 --- /dev/null +++ b/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, + message: proto::UpdateChannels, + cx: &mut AppContext, +) { + channel_store.update(cx, |store, cx| store.update_channels(message, cx)); +} + +fn assert_channels( + channel_store: &ModelHandle, + 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::>(); + assert_eq!(actual, expected_channels); + }); +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index af33c738ce..a48b2849ae 100644 --- a/crates/client/src/client.rs +++ b/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; diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index a000fbd92e..98ad2afb8a 100644 --- a/crates/collab/src/tests.rs +++ b/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(), diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 632bfdca49..ffd517f52a 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/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, + })] ) }); } diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bdeac59af9..bdd01e4299 100644 --- a/crates/collab_ui/src/panel.rs +++ b/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, selection: Option, user_store: ModelHandle, + channel_store: ModelHandle, project: ModelHandle, match_candidates: Vec, list_state: ListState, @@ -109,8 +110,10 @@ enum ContactEntry { peer_id: PeerId, is_last: bool, }, + ChannelInvite(Arc), IncomingRequest(Arc), OutgoingRequest(Arc), + Channel(Arc), Contact { contact: Arc, 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) { + 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, + ) -> AnyElement { + let channel_id = channel.id; + MouseEventHandler::::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, + user_store: ModelHandle, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + 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::::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::::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_store: ModelHandle, @@ -1384,6 +1569,18 @@ impl CollabPanel { .detach(); } + fn respond_to_channel_invite( + &mut self, + channel_id: u64, + accept: bool, + cx: &mut ViewContext, + ) { + 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) { + 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; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 434975216a..95077649a8 100644 --- a/crates/workspace/src/workspace.rs +++ b/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(cx: &mut AppContext) { pub struct AppState { pub languages: Arc, - pub client: Arc, - pub user_store: ModelHandle, + pub client: Arc, + pub user_store: ModelHandle, + pub channel_store: ModelHandle, pub fs: Arc, pub build_window_options: fn(Option, Option, &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, cx: &mut ViewContext) -> 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(())), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e44ab3e33a..34c1232712 100644 --- a/crates/zed/src/main.rs +++ b/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,