diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 1d3ed24d1b..fcd0083c3b 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -30,6 +30,11 @@ impl Entity for ChannelStore { type Event = (); } +pub enum ChannelMemberStatus { + Invited, + Member, +} + impl ChannelStore { pub fn new( client: Arc, @@ -115,6 +120,26 @@ impl ChannelStore { } } + pub fn get_channel_members( + &self, + channel_id: ChannelId, + ) -> impl Future>> { + let client = self.client.clone(); + async move { + let response = client + .request(proto::GetChannelMembers { channel_id }) + .await?; + let mut result = HashMap::default(); + for member_id in response.members { + result.insert(member_id, ChannelMemberStatus::Member); + } + for invitee_id in response.invited_members { + result.insert(invitee_id, ChannelMemberStatus::Invited); + } + Ok(result) + } + } + pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { let client = self.client.clone(); async move { diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/collab_panel.rs similarity index 99% rename from crates/collab_ui/src/panel.rs rename to crates/collab_ui/src/collab_panel.rs index 4092351a75..daad527979 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -42,7 +42,7 @@ use workspace::{ use crate::face_pile::FacePile; -use self::channel_modal::ChannelModal; +use self::channel_modal::build_channel_modal; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { @@ -1682,7 +1682,12 @@ impl CollabPanel { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { - ChannelModal::new(action.channel_id, self.channel_store.clone(), cx) + build_channel_modal( + self.user_store.clone(), + self.channel_store.clone(), + action.channel_id, + cx, + ) }) }) }); diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs new file mode 100644 index 0000000000..0cf24dbaf5 --- /dev/null +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -0,0 +1,178 @@ +use client::{ + ChannelId, ChannelMemberStatus, ChannelStore, ContactRequestStatus, User, UserId, UserStore, +}; +use collections::HashMap; +use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext}; +use picker::{Picker, PickerDelegate, PickerEvent}; +use std::sync::Arc; +use util::TryFutureExt; + +pub fn init(cx: &mut AppContext) { + Picker::::init(cx); +} + +pub type ChannelModal = Picker; + +pub fn build_channel_modal( + user_store: ModelHandle, + channel_store: ModelHandle, + channel: ChannelId, + cx: &mut ViewContext, +) -> ChannelModal { + Picker::new( + ChannelModalDelegate { + potential_contacts: Arc::from([]), + selected_index: 0, + user_store, + channel_store, + channel_id: channel, + member_statuses: Default::default(), + }, + cx, + ) + .with_theme(|theme| theme.picker.clone()) +} + +pub struct ChannelModalDelegate { + potential_contacts: Arc<[Arc]>, + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + selected_index: usize, + member_statuses: HashMap, +} + +impl PickerDelegate for ChannelModalDelegate { + fn placeholder_text(&self) -> Arc { + "Search collaborator by username...".into() + } + + fn match_count(&self) -> usize { + self.potential_contacts.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let search_users = self + .user_store + .update(cx, |store, cx| store.fuzzy_search_users(query, cx)); + + cx.spawn(|picker, mut cx| async move { + async { + let potential_contacts = search_users.await?; + picker.update(&mut cx, |picker, cx| { + picker.delegate_mut().potential_contacts = potential_contacts.into(); + cx.notify(); + })?; + anyhow::Ok(()) + } + .log_err() + .await; + }) + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + if let Some(user) = self.potential_contacts.get(self.selected_index) { + let user_store = self.user_store.read(cx); + match user_store.contact_request_status(user) { + ContactRequestStatus::None | ContactRequestStatus::RequestReceived => { + self.user_store + .update(cx, |store, cx| store.request_contact(user.id, cx)) + .detach(); + } + ContactRequestStatus::RequestSent => { + self.user_store + .update(cx, |store, cx| store.remove_contact(user.id, cx)) + .detach(); + } + _ => {} + } + } + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + cx.emit(PickerEvent::Dismiss); + } + + fn render_header( + &self, + cx: &mut ViewContext>, + ) -> Option>> { + let theme = &theme::current(cx).collab_panel.channel_modal; + + self.channel_store + .read(cx) + .channel_for_id(self.channel_id) + .map(|channel| { + Label::new( + format!("Add members for #{}", channel.name), + theme.picker.item.default_style().label.clone(), + ) + .into_any() + }) + } + + fn render_match( + &self, + ix: usize, + mouse_state: &mut MouseState, + selected: bool, + cx: &gpui::AppContext, + ) -> AnyElement> { + let theme = &theme::current(cx).collab_panel.channel_modal; + let user = &self.potential_contacts[ix]; + let request_status = self.member_statuses.get(&user.id); + + let icon_path = match request_status { + Some(ChannelMemberStatus::Member) => Some("icons/check_8.svg"), + Some(ChannelMemberStatus::Invited) => Some("icons/x_mark_8.svg"), + None => None, + }; + let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { + &theme.disabled_contact_button + } else { + &theme.contact_button + }; + let style = theme.picker.item.in_state(selected).style_for(mouse_state); + Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::from_data(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + })) + .with_child( + Label::new(user.github_login.clone(), style.label.clone()) + .contained() + .with_style(theme.contact_username) + .aligned() + .left(), + ) + .with_children(icon_path.map(|icon_path| { + Svg::new(icon_path) + .with_color(button_style.color) + .constrained() + .with_width(button_style.icon_width) + .aligned() + .contained() + .with_style(button_style.container) + .constrained() + .with_width(button_style.button_width) + .with_height(button_style.button_width) + .aligned() + .flex_float() + })) + .contained() + .with_style(style.container) + .constrained() + .with_height(theme.row_height) + .into_any() + } +} diff --git a/crates/collab_ui/src/panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs similarity index 100% rename from crates/collab_ui/src/panel/contact_finder.rs rename to crates/collab_ui/src/collab_panel/contact_finder.rs diff --git a/crates/collab_ui/src/panel/panel_settings.rs b/crates/collab_ui/src/collab_panel/panel_settings.rs similarity index 100% rename from crates/collab_ui/src/panel/panel_settings.rs rename to crates/collab_ui/src/collab_panel/panel_settings.rs diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index c42ed34de6..1e48026f46 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,9 +1,9 @@ +pub mod collab_panel; mod collab_titlebar_item; mod contact_notification; mod face_pile; mod incoming_call_notification; mod notifications; -pub mod panel; mod project_shared_notification; mod sharing_status_indicator; @@ -22,7 +22,7 @@ actions!( pub fn init(app_state: &Arc, cx: &mut AppContext) { vcs_menu::init(cx); collab_titlebar_item::init(cx); - panel::init(app_state.client.clone(), cx); + collab_panel::init(app_state.client.clone(), cx); incoming_call_notification::init(&app_state, cx); project_shared_notification::init(&app_state, cx); sharing_status_indicator::init(cx); diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs deleted file mode 100644 index 96424114c7..0000000000 --- a/crates/collab_ui/src/panel/channel_modal.rs +++ /dev/null @@ -1,119 +0,0 @@ -use client::{ChannelId, ChannelStore}; -use editor::Editor; -use gpui::{ - elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, View, ViewContext, ViewHandle, -}; -use menu::Cancel; -use workspace::{item::ItemHandle, Modal}; - -pub fn init(cx: &mut AppContext) { - cx.add_action(ChannelModal::cancel) -} - -pub struct ChannelModal { - has_focus: bool, - filter_editor: ViewHandle, - selection: usize, - list_state: ListState, - channel_store: ModelHandle, - channel_id: ChannelId, -} - -pub enum Event { - Dismiss, -} - -impl Entity for ChannelModal { - type Event = Event; -} - -impl ChannelModal { - pub fn new( - channel_id: ChannelId, - channel_store: ModelHandle, - cx: &mut ViewContext, - ) -> Self { - let input_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line(None, cx); - editor.set_placeholder_text("Add a member", cx); - editor - }); - - let list_state = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { - Empty::new().into_any() - }); - - ChannelModal { - has_focus: false, - filter_editor: input_editor, - selection: 0, - list_state, - channel_id, - channel_store, - } - } - - pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - self.dismiss(cx); - } - - fn dismiss(&mut self, cx: &mut ViewContext) { - cx.emit(Event::Dismiss) - } -} - -impl View for ChannelModal { - fn ui_name() -> &'static str { - "Channel Modal" - } - - fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { - let theme = theme::current(cx).clone(); - let style = &theme.collab_panel.modal; - let modal_container = theme::current(cx).picker.container.clone(); - - enum ChannelModal {} - MouseEventHandler::::new(0, cx, |_, cx| { - Flex::column() - .with_child(ChildView::new(self.filter_editor.as_any(), cx)) - .with_child( - List::new(self.list_state.clone()) - .constrained() - .with_width(style.width) - .flex(1., true) - .into_any(), - ) - .contained() - .with_style(modal_container) - .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") - } - - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - if cx.is_self_focused() { - cx.focus(&self.filter_editor); - } - } - - fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { - self.has_focus = false; - } -} - -impl Modal for ChannelModal { - fn has_focus(&self) -> bool { - self.has_focus - } - - fn dismiss_on_event(event: &Self::Event) -> bool { - match event { - Event::Dismiss => true, - } - } -} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index c4fb5aa653..1fdeef98f0 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -138,6 +138,8 @@ message Envelope { UpdateChannels update_channels = 124; JoinChannel join_channel = 126; RemoveChannel remove_channel = 127; + GetChannelMembers get_channel_members = 128; + GetChannelMembersResponse get_channel_members_response = 129; } } @@ -886,6 +888,14 @@ message RemoveChannel { uint64 channel_id = 1; } +message GetChannelMembers { + uint64 channel_id = 1; +} + +message GetChannelMembersResponse { + repeated uint64 members = 1; + repeated uint64 invited_members = 2; +} message CreateChannel { string name = 1; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 07d54ce4db..c23bbb23e4 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -244,7 +244,9 @@ messages!( (UpdateWorktreeSettings, Foreground), (UpdateDiffBase, Foreground), (GetPrivateUserInfo, Foreground), - (GetPrivateUserInfoResponse, Foreground) + (GetPrivateUserInfoResponse, Foreground), + (GetChannelMembers, Foreground), + (GetChannelMembersResponse, Foreground) ); request_messages!( @@ -296,6 +298,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), + (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8f0ceeab88..c557fbcf52 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,7 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, - pub modal: ChannelModal, + pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub leave_call_button: IconButton, @@ -247,7 +247,12 @@ pub struct CollabPanel { #[derive(Deserialize, Default, JsonSchema)] pub struct ChannelModal { - pub width: f32, + pub picker: Picker, + pub row_height: f32, + pub contact_avatar: ImageStyle, + pub contact_username: ContainerStyle, + pub contact_button: IconButton, + pub disabled_contact_button: IconButton, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a779f39f57..500a82d1ce 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -209,9 +209,9 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { ); cx.add_action( |workspace: &mut Workspace, - _: &collab_ui::panel::ToggleFocus, + _: &collab_ui::collab_panel::ToggleFocus, cx: &mut ViewContext| { - workspace.toggle_panel_focus::(cx); + workspace.toggle_panel_focus::(cx); }, ); cx.add_action( @@ -333,7 +333,7 @@ pub fn initialize_workspace( let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); let channels_panel = - collab_ui::panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); + collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!( project_panel, terminal_panel, diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 95ae337cbc..3eff0e4b9a 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -1,9 +1,74 @@ import { useTheme } from "../theme" +import { background, border, foreground, text } from "./components" +import picker from "./picker" export default function contacts_panel(): any { const theme = useTheme() + const side_margin = 6 + const contact_button = { + background: background(theme.middle, "variant"), + color: foreground(theme.middle, "variant"), + icon_width: 8, + button_width: 16, + corner_radius: 8, + } + + const picker_style = picker() + const picker_input = { + background: background(theme.middle, "on"), + corner_radius: 6, + text: text(theme.middle, "mono"), + placeholder_text: text(theme.middle, "mono", "on", "disabled", { + size: "xs", + }), + selection: theme.players[0], + border: border(theme.middle), + padding: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + margin: { + left: side_margin, + right: side_margin, + }, + } + return { - width: 100, + picker: { + empty_container: {}, + item: { + ...picker_style.item, + margin: { left: side_margin, right: side_margin }, + }, + no_matches: picker_style.no_matches, + input_editor: picker_input, + empty_input_editor: picker_input, + header: picker_style.header, + footer: picker_style.footer, + }, + row_height: 28, + contact_avatar: { + corner_radius: 10, + width: 18, + }, + contact_username: { + padding: { + left: 8, + }, + }, + contact_button: { + ...contact_button, + hover: { + background: background(theme.middle, "variant", "hovered"), + }, + }, + disabled_contact_button: { + ...contact_button, + background: background(theme.middle, "disabled"), + color: foreground(theme.middle, "disabled"), + }, } } diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 37145d0c46..ea550dea6b 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -52,7 +52,7 @@ export default function contacts_panel(): any { } return { - modal: channel_modal(), + channel_modal: channel_modal(), background: background(layer), padding: { top: 12,