1mod channel_modal;
2mod contact_finder;
3mod panel_settings;
4
5use anyhow::Result;
6use call::ActiveCall;
7use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
8use client::{proto::PeerId, Client, Contact, User, UserStore};
9use context_menu::{ContextMenu, ContextMenuItem};
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Cancel, Editor};
12use futures::StreamExt;
13use fuzzy::{match_strings, StringMatchCandidate};
14use gpui::{
15 actions,
16 elements::{
17 Canvas, ChildView, Component, Empty, Flex, Image, Label, List, ListOffset, ListState,
18 MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, SafeStylable,
19 Stack, Svg,
20 },
21 fonts::TextStyle,
22 geometry::{
23 rect::RectF,
24 vector::{vec2f, Vector2F},
25 },
26 impl_actions,
27 platform::{CursorStyle, MouseButton, PromptLevel},
28 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, FontCache, ModelHandle,
29 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
30};
31use menu::{Confirm, SelectNext, SelectPrev};
32use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
33use project::{Fs, Project};
34use serde_derive::{Deserialize, Serialize};
35use settings::SettingsStore;
36use staff_mode::StaffMode;
37use std::{borrow::Cow, mem, sync::Arc};
38use theme::{components::ComponentExt, IconButton};
39use util::{iife, ResultExt, TryFutureExt};
40use workspace::{
41 dock::{DockPosition, Panel},
42 item::ItemHandle,
43 Workspace,
44};
45
46use crate::{
47 channel_view::{self, ChannelView},
48 face_pile::FacePile,
49};
50use channel_modal::ChannelModal;
51
52use self::contact_finder::ContactFinder;
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55struct RemoveChannel {
56 channel_id: u64,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60struct ToggleCollapse {
61 channel_id: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65struct NewChannel {
66 channel_id: u64,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70struct InviteMembers {
71 channel_id: u64,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
75struct ManageMembers {
76 channel_id: u64,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
80struct RenameChannel {
81 channel_id: u64,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
85struct OpenChannelBuffer {
86 channel_id: u64,
87}
88
89actions!(
90 collab_panel,
91 [
92 ToggleFocus,
93 Remove,
94 Secondary,
95 CollapseSelectedChannel,
96 ExpandSelectedChannel
97 ]
98);
99
100impl_actions!(
101 collab_panel,
102 [
103 RemoveChannel,
104 NewChannel,
105 InviteMembers,
106 ManageMembers,
107 RenameChannel,
108 ToggleCollapse,
109 OpenChannelBuffer
110 ]
111);
112
113const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
114
115pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
116 settings::register::<panel_settings::CollaborationPanelSettings>(cx);
117 contact_finder::init(cx);
118 channel_modal::init(cx);
119 channel_view::init(cx);
120
121 cx.add_action(CollabPanel::cancel);
122 cx.add_action(CollabPanel::select_next);
123 cx.add_action(CollabPanel::select_prev);
124 cx.add_action(CollabPanel::confirm);
125 cx.add_action(CollabPanel::remove);
126 cx.add_action(CollabPanel::remove_selected_channel);
127 cx.add_action(CollabPanel::show_inline_context_menu);
128 cx.add_action(CollabPanel::new_subchannel);
129 cx.add_action(CollabPanel::invite_members);
130 cx.add_action(CollabPanel::manage_members);
131 cx.add_action(CollabPanel::rename_selected_channel);
132 cx.add_action(CollabPanel::rename_channel);
133 cx.add_action(CollabPanel::toggle_channel_collapsed);
134 cx.add_action(CollabPanel::collapse_selected_channel);
135 cx.add_action(CollabPanel::expand_selected_channel);
136 cx.add_action(CollabPanel::open_channel_buffer);
137}
138
139#[derive(Debug)]
140pub enum ChannelEditingState {
141 Create {
142 parent_id: Option<u64>,
143 pending_name: Option<String>,
144 },
145 Rename {
146 channel_id: u64,
147 pending_name: Option<String>,
148 },
149}
150
151impl ChannelEditingState {
152 fn pending_name(&self) -> Option<&str> {
153 match self {
154 ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
155 ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
156 }
157 }
158}
159
160pub struct CollabPanel {
161 width: Option<f32>,
162 fs: Arc<dyn Fs>,
163 has_focus: bool,
164 pending_serialization: Task<Option<()>>,
165 context_menu: ViewHandle<ContextMenu>,
166 filter_editor: ViewHandle<Editor>,
167 channel_name_editor: ViewHandle<Editor>,
168 channel_editing_state: Option<ChannelEditingState>,
169 entries: Vec<ListEntry>,
170 selection: Option<usize>,
171 user_store: ModelHandle<UserStore>,
172 client: Arc<Client>,
173 channel_store: ModelHandle<ChannelStore>,
174 project: ModelHandle<Project>,
175 match_candidates: Vec<StringMatchCandidate>,
176 list_state: ListState<Self>,
177 subscriptions: Vec<Subscription>,
178 collapsed_sections: Vec<Section>,
179 collapsed_channels: Vec<ChannelId>,
180 workspace: WeakViewHandle<Workspace>,
181 context_menu_on_selected: bool,
182}
183
184#[derive(Serialize, Deserialize)]
185struct SerializedChannelsPanel {
186 width: Option<f32>,
187 collapsed_channels: Vec<ChannelId>,
188}
189
190#[derive(Debug)]
191pub enum Event {
192 DockPositionChanged,
193 Focus,
194 Dismissed,
195}
196
197#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
198enum Section {
199 ActiveCall,
200 Channels,
201 ChannelInvites,
202 ContactRequests,
203 Contacts,
204 Online,
205 Offline,
206}
207
208#[derive(Clone, Debug)]
209enum ListEntry {
210 Header(Section, usize),
211 CallParticipant {
212 user: Arc<User>,
213 is_pending: bool,
214 },
215 ParticipantProject {
216 project_id: u64,
217 worktree_root_names: Vec<String>,
218 host_user_id: u64,
219 is_last: bool,
220 },
221 ParticipantScreen {
222 peer_id: PeerId,
223 is_last: bool,
224 },
225 IncomingRequest(Arc<User>),
226 OutgoingRequest(Arc<User>),
227 ChannelInvite(Arc<Channel>),
228 Channel {
229 channel: Arc<Channel>,
230 depth: usize,
231 },
232 ChannelNotes {
233 channel_id: ChannelId,
234 },
235 ChannelEditor {
236 depth: usize,
237 },
238 Contact {
239 contact: Arc<Contact>,
240 calling: bool,
241 },
242 ContactPlaceholder,
243}
244
245impl Entity for CollabPanel {
246 type Event = Event;
247}
248
249impl CollabPanel {
250 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
251 cx.add_view::<Self, _>(|cx| {
252 let view_id = cx.view_id();
253
254 let filter_editor = cx.add_view(|cx| {
255 let mut editor = Editor::single_line(
256 Some(Arc::new(|theme| {
257 theme.collab_panel.user_query_editor.clone()
258 })),
259 cx,
260 );
261 editor.set_placeholder_text("Filter channels, contacts", cx);
262 editor
263 });
264
265 cx.subscribe(&filter_editor, |this, _, event, cx| {
266 if let editor::Event::BufferEdited = event {
267 let query = this.filter_editor.read(cx).text(cx);
268 if !query.is_empty() {
269 this.selection.take();
270 }
271 this.update_entries(true, cx);
272 if !query.is_empty() {
273 this.selection = this
274 .entries
275 .iter()
276 .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
277 }
278 }
279 })
280 .detach();
281
282 let channel_name_editor = cx.add_view(|cx| {
283 Editor::single_line(
284 Some(Arc::new(|theme| {
285 theme.collab_panel.user_query_editor.clone()
286 })),
287 cx,
288 )
289 });
290
291 cx.subscribe(&channel_name_editor, |this, _, event, cx| {
292 if let editor::Event::Blurred = event {
293 if let Some(state) = &this.channel_editing_state {
294 if state.pending_name().is_some() {
295 return;
296 }
297 }
298 this.take_editing_state(cx);
299 this.update_entries(false, cx);
300 cx.notify();
301 }
302 })
303 .detach();
304
305 let list_state =
306 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
307 let theme = theme::current(cx).clone();
308 let is_selected = this.selection == Some(ix);
309 let current_project_id = this.project.read(cx).remote_id();
310
311 match &this.entries[ix] {
312 ListEntry::Header(section, depth) => {
313 let is_collapsed = this.collapsed_sections.contains(section);
314 this.render_header(
315 *section,
316 &theme,
317 *depth,
318 is_selected,
319 is_collapsed,
320 cx,
321 )
322 }
323 ListEntry::CallParticipant { user, is_pending } => {
324 Self::render_call_participant(
325 user,
326 *is_pending,
327 is_selected,
328 &theme.collab_panel,
329 )
330 }
331 ListEntry::ParticipantProject {
332 project_id,
333 worktree_root_names,
334 host_user_id,
335 is_last,
336 } => Self::render_participant_project(
337 *project_id,
338 worktree_root_names,
339 *host_user_id,
340 Some(*project_id) == current_project_id,
341 *is_last,
342 is_selected,
343 &theme.collab_panel,
344 cx,
345 ),
346 ListEntry::ParticipantScreen { peer_id, is_last } => {
347 Self::render_participant_screen(
348 *peer_id,
349 *is_last,
350 is_selected,
351 &theme.collab_panel,
352 cx,
353 )
354 }
355 ListEntry::Channel { channel, depth } => {
356 let channel_row = this.render_channel(
357 &*channel,
358 *depth,
359 &theme.collab_panel,
360 is_selected,
361 cx,
362 );
363
364 if is_selected && this.context_menu_on_selected {
365 Stack::new()
366 .with_child(channel_row)
367 .with_child(
368 ChildView::new(&this.context_menu, cx)
369 .aligned()
370 .bottom()
371 .right(),
372 )
373 .into_any()
374 } else {
375 return channel_row;
376 }
377 }
378 ListEntry::ChannelNotes { channel_id } => this.render_channel_notes(
379 *channel_id,
380 &theme.collab_panel,
381 is_selected,
382 cx,
383 ),
384 ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
385 channel.clone(),
386 this.channel_store.clone(),
387 &theme.collab_panel,
388 is_selected,
389 cx,
390 ),
391 ListEntry::IncomingRequest(user) => Self::render_contact_request(
392 user.clone(),
393 this.user_store.clone(),
394 &theme.collab_panel,
395 true,
396 is_selected,
397 cx,
398 ),
399 ListEntry::OutgoingRequest(user) => Self::render_contact_request(
400 user.clone(),
401 this.user_store.clone(),
402 &theme.collab_panel,
403 false,
404 is_selected,
405 cx,
406 ),
407 ListEntry::Contact { contact, calling } => Self::render_contact(
408 contact,
409 *calling,
410 &this.project,
411 &theme.collab_panel,
412 is_selected,
413 cx,
414 ),
415 ListEntry::ChannelEditor { depth } => {
416 this.render_channel_editor(&theme, *depth, cx)
417 }
418 ListEntry::ContactPlaceholder => {
419 this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
420 }
421 }
422 });
423
424 let mut this = Self {
425 width: None,
426 has_focus: false,
427 fs: workspace.app_state().fs.clone(),
428 pending_serialization: Task::ready(None),
429 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
430 channel_name_editor,
431 filter_editor,
432 entries: Vec::default(),
433 channel_editing_state: None,
434 selection: None,
435 user_store: workspace.user_store().clone(),
436 channel_store: workspace.app_state().channel_store.clone(),
437 project: workspace.project().clone(),
438 subscriptions: Vec::default(),
439 match_candidates: Vec::default(),
440 collapsed_sections: vec![Section::Offline],
441 collapsed_channels: Vec::default(),
442 workspace: workspace.weak_handle(),
443 client: workspace.app_state().client.clone(),
444 context_menu_on_selected: true,
445 list_state,
446 };
447
448 this.update_entries(false, cx);
449
450 // Update the dock position when the setting changes.
451 let mut old_dock_position = this.position(cx);
452 this.subscriptions
453 .push(
454 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
455 let new_dock_position = this.position(cx);
456 if new_dock_position != old_dock_position {
457 old_dock_position = new_dock_position;
458 cx.emit(Event::DockPositionChanged);
459 }
460 cx.notify();
461 }),
462 );
463
464 let active_call = ActiveCall::global(cx);
465 this.subscriptions
466 .push(cx.observe(&this.user_store, |this, _, cx| {
467 this.update_entries(true, cx)
468 }));
469 this.subscriptions
470 .push(cx.observe(&this.channel_store, |this, _, cx| {
471 this.update_entries(true, cx)
472 }));
473 this.subscriptions
474 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
475 this.subscriptions.push(
476 cx.observe_global::<StaffMode, _>(move |this, cx| this.update_entries(true, cx)),
477 );
478 this.subscriptions.push(cx.subscribe(
479 &this.channel_store,
480 |this, _channel_store, e, cx| match e {
481 ChannelEvent::ChannelCreated(channel_id)
482 | ChannelEvent::ChannelRenamed(channel_id) => {
483 if this.take_editing_state(cx) {
484 this.update_entries(false, cx);
485 this.selection = this.entries.iter().position(|entry| {
486 if let ListEntry::Channel { channel, .. } = entry {
487 channel.id == *channel_id
488 } else {
489 false
490 }
491 });
492 }
493 }
494 },
495 ));
496
497 this
498 })
499 }
500
501 pub fn load(
502 workspace: WeakViewHandle<Workspace>,
503 cx: AsyncAppContext,
504 ) -> Task<Result<ViewHandle<Self>>> {
505 cx.spawn(|mut cx| async move {
506 let serialized_panel = if let Some(panel) = cx
507 .background()
508 .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
509 .await
510 .log_err()
511 .flatten()
512 {
513 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
514 } else {
515 None
516 };
517
518 workspace.update(&mut cx, |workspace, cx| {
519 let panel = CollabPanel::new(workspace, cx);
520 if let Some(serialized_panel) = serialized_panel {
521 panel.update(cx, |panel, cx| {
522 panel.width = serialized_panel.width;
523 panel.collapsed_channels = serialized_panel.collapsed_channels;
524 cx.notify();
525 });
526 }
527 panel
528 })
529 })
530 }
531
532 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
533 let width = self.width;
534 let collapsed_channels = self.collapsed_channels.clone();
535 self.pending_serialization = cx.background().spawn(
536 async move {
537 KEY_VALUE_STORE
538 .write_kvp(
539 COLLABORATION_PANEL_KEY.into(),
540 serde_json::to_string(&SerializedChannelsPanel {
541 width,
542 collapsed_channels,
543 })?,
544 )
545 .await?;
546 anyhow::Ok(())
547 }
548 .log_err(),
549 );
550 }
551
552 fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
553 let channel_store = self.channel_store.read(cx);
554 let user_store = self.user_store.read(cx);
555 let query = self.filter_editor.read(cx).text(cx);
556 let executor = cx.background().clone();
557
558 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
559 let old_entries = mem::take(&mut self.entries);
560
561 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
562 self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
563
564 if !self.collapsed_sections.contains(&Section::ActiveCall) {
565 let room = room.read(cx);
566
567 if let Some(channel_id) = room.channel_id() {
568 self.entries.push(ListEntry::ChannelNotes { channel_id })
569 }
570
571 // Populate the active user.
572 if let Some(user) = user_store.current_user() {
573 self.match_candidates.clear();
574 self.match_candidates.push(StringMatchCandidate {
575 id: 0,
576 string: user.github_login.clone(),
577 char_bag: user.github_login.chars().collect(),
578 });
579 let matches = executor.block(match_strings(
580 &self.match_candidates,
581 &query,
582 true,
583 usize::MAX,
584 &Default::default(),
585 executor.clone(),
586 ));
587 if !matches.is_empty() {
588 let user_id = user.id;
589 self.entries.push(ListEntry::CallParticipant {
590 user,
591 is_pending: false,
592 });
593 let mut projects = room.local_participant().projects.iter().peekable();
594 while let Some(project) = projects.next() {
595 self.entries.push(ListEntry::ParticipantProject {
596 project_id: project.id,
597 worktree_root_names: project.worktree_root_names.clone(),
598 host_user_id: user_id,
599 is_last: projects.peek().is_none(),
600 });
601 }
602 }
603 }
604
605 // Populate remote participants.
606 self.match_candidates.clear();
607 self.match_candidates
608 .extend(room.remote_participants().iter().map(|(_, participant)| {
609 StringMatchCandidate {
610 id: participant.user.id as usize,
611 string: participant.user.github_login.clone(),
612 char_bag: participant.user.github_login.chars().collect(),
613 }
614 }));
615 let matches = executor.block(match_strings(
616 &self.match_candidates,
617 &query,
618 true,
619 usize::MAX,
620 &Default::default(),
621 executor.clone(),
622 ));
623 for mat in matches {
624 let user_id = mat.candidate_id as u64;
625 let participant = &room.remote_participants()[&user_id];
626 self.entries.push(ListEntry::CallParticipant {
627 user: participant.user.clone(),
628 is_pending: false,
629 });
630 let mut projects = participant.projects.iter().peekable();
631 while let Some(project) = projects.next() {
632 self.entries.push(ListEntry::ParticipantProject {
633 project_id: project.id,
634 worktree_root_names: project.worktree_root_names.clone(),
635 host_user_id: participant.user.id,
636 is_last: projects.peek().is_none()
637 && participant.video_tracks.is_empty(),
638 });
639 }
640 if !participant.video_tracks.is_empty() {
641 self.entries.push(ListEntry::ParticipantScreen {
642 peer_id: participant.peer_id,
643 is_last: true,
644 });
645 }
646 }
647
648 // Populate pending participants.
649 self.match_candidates.clear();
650 self.match_candidates
651 .extend(room.pending_participants().iter().enumerate().map(
652 |(id, participant)| StringMatchCandidate {
653 id,
654 string: participant.github_login.clone(),
655 char_bag: participant.github_login.chars().collect(),
656 },
657 ));
658 let matches = executor.block(match_strings(
659 &self.match_candidates,
660 &query,
661 true,
662 usize::MAX,
663 &Default::default(),
664 executor.clone(),
665 ));
666 self.entries
667 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
668 user: room.pending_participants()[mat.candidate_id].clone(),
669 is_pending: true,
670 }));
671 }
672 }
673
674 let mut request_entries = Vec::new();
675 if self.include_channels_section(cx) {
676 self.entries.push(ListEntry::Header(Section::Channels, 0));
677
678 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
679 self.match_candidates.clear();
680 self.match_candidates
681 .extend(
682 channel_store
683 .channels()
684 .enumerate()
685 .map(|(ix, (_, channel))| StringMatchCandidate {
686 id: ix,
687 string: channel.name.clone(),
688 char_bag: channel.name.chars().collect(),
689 }),
690 );
691 let matches = executor.block(match_strings(
692 &self.match_candidates,
693 &query,
694 true,
695 usize::MAX,
696 &Default::default(),
697 executor.clone(),
698 ));
699 if let Some(state) = &self.channel_editing_state {
700 if matches!(
701 state,
702 ChannelEditingState::Create {
703 parent_id: None,
704 ..
705 }
706 ) {
707 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
708 }
709 }
710 let mut collapse_depth = None;
711 for mat in matches {
712 let (depth, channel) =
713 channel_store.channel_at_index(mat.candidate_id).unwrap();
714
715 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
716 collapse_depth = Some(depth);
717 } else if let Some(collapsed_depth) = collapse_depth {
718 if depth > collapsed_depth {
719 continue;
720 }
721 if self.is_channel_collapsed(channel.id) {
722 collapse_depth = Some(depth);
723 } else {
724 collapse_depth = None;
725 }
726 }
727
728 match &self.channel_editing_state {
729 Some(ChannelEditingState::Create { parent_id, .. })
730 if *parent_id == Some(channel.id) =>
731 {
732 self.entries.push(ListEntry::Channel {
733 channel: channel.clone(),
734 depth,
735 });
736 self.entries
737 .push(ListEntry::ChannelEditor { depth: depth + 1 });
738 }
739 Some(ChannelEditingState::Rename { channel_id, .. })
740 if *channel_id == channel.id =>
741 {
742 self.entries.push(ListEntry::ChannelEditor { depth });
743 }
744 _ => {
745 self.entries.push(ListEntry::Channel {
746 channel: channel.clone(),
747 depth,
748 });
749 }
750 }
751 }
752 }
753
754 let channel_invites = channel_store.channel_invitations();
755 if !channel_invites.is_empty() {
756 self.match_candidates.clear();
757 self.match_candidates
758 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
759 StringMatchCandidate {
760 id: ix,
761 string: channel.name.clone(),
762 char_bag: channel.name.chars().collect(),
763 }
764 }));
765 let matches = executor.block(match_strings(
766 &self.match_candidates,
767 &query,
768 true,
769 usize::MAX,
770 &Default::default(),
771 executor.clone(),
772 ));
773 request_entries.extend(matches.iter().map(|mat| {
774 ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
775 }));
776
777 if !request_entries.is_empty() {
778 self.entries
779 .push(ListEntry::Header(Section::ChannelInvites, 1));
780 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
781 self.entries.append(&mut request_entries);
782 }
783 }
784 }
785 }
786
787 self.entries.push(ListEntry::Header(Section::Contacts, 0));
788
789 request_entries.clear();
790 let incoming = user_store.incoming_contact_requests();
791 if !incoming.is_empty() {
792 self.match_candidates.clear();
793 self.match_candidates
794 .extend(
795 incoming
796 .iter()
797 .enumerate()
798 .map(|(ix, user)| StringMatchCandidate {
799 id: ix,
800 string: user.github_login.clone(),
801 char_bag: user.github_login.chars().collect(),
802 }),
803 );
804 let matches = executor.block(match_strings(
805 &self.match_candidates,
806 &query,
807 true,
808 usize::MAX,
809 &Default::default(),
810 executor.clone(),
811 ));
812 request_entries.extend(
813 matches
814 .iter()
815 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
816 );
817 }
818
819 let outgoing = user_store.outgoing_contact_requests();
820 if !outgoing.is_empty() {
821 self.match_candidates.clear();
822 self.match_candidates
823 .extend(
824 outgoing
825 .iter()
826 .enumerate()
827 .map(|(ix, user)| StringMatchCandidate {
828 id: ix,
829 string: user.github_login.clone(),
830 char_bag: user.github_login.chars().collect(),
831 }),
832 );
833 let matches = executor.block(match_strings(
834 &self.match_candidates,
835 &query,
836 true,
837 usize::MAX,
838 &Default::default(),
839 executor.clone(),
840 ));
841 request_entries.extend(
842 matches
843 .iter()
844 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
845 );
846 }
847
848 if !request_entries.is_empty() {
849 self.entries
850 .push(ListEntry::Header(Section::ContactRequests, 1));
851 if !self.collapsed_sections.contains(&Section::ContactRequests) {
852 self.entries.append(&mut request_entries);
853 }
854 }
855
856 let contacts = user_store.contacts();
857 if !contacts.is_empty() {
858 self.match_candidates.clear();
859 self.match_candidates
860 .extend(
861 contacts
862 .iter()
863 .enumerate()
864 .map(|(ix, contact)| StringMatchCandidate {
865 id: ix,
866 string: contact.user.github_login.clone(),
867 char_bag: contact.user.github_login.chars().collect(),
868 }),
869 );
870
871 let matches = executor.block(match_strings(
872 &self.match_candidates,
873 &query,
874 true,
875 usize::MAX,
876 &Default::default(),
877 executor.clone(),
878 ));
879
880 let (online_contacts, offline_contacts) = matches
881 .iter()
882 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
883
884 for (matches, section) in [
885 (online_contacts, Section::Online),
886 (offline_contacts, Section::Offline),
887 ] {
888 if !matches.is_empty() {
889 self.entries.push(ListEntry::Header(section, 1));
890 if !self.collapsed_sections.contains(§ion) {
891 let active_call = &ActiveCall::global(cx).read(cx);
892 for mat in matches {
893 let contact = &contacts[mat.candidate_id];
894 self.entries.push(ListEntry::Contact {
895 contact: contact.clone(),
896 calling: active_call.pending_invites().contains(&contact.user.id),
897 });
898 }
899 }
900 }
901 }
902 }
903
904 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
905 self.entries.push(ListEntry::ContactPlaceholder);
906 }
907
908 if select_same_item {
909 if let Some(prev_selected_entry) = prev_selected_entry {
910 self.selection.take();
911 for (ix, entry) in self.entries.iter().enumerate() {
912 if *entry == prev_selected_entry {
913 self.selection = Some(ix);
914 break;
915 }
916 }
917 }
918 } else {
919 self.selection = self.selection.and_then(|prev_selection| {
920 if self.entries.is_empty() {
921 None
922 } else {
923 Some(prev_selection.min(self.entries.len() - 1))
924 }
925 });
926 }
927
928 let old_scroll_top = self.list_state.logical_scroll_top();
929 self.list_state.reset(self.entries.len());
930
931 // Attempt to maintain the same scroll position.
932 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
933 let new_scroll_top = self
934 .entries
935 .iter()
936 .position(|entry| entry == old_top_entry)
937 .map(|item_ix| ListOffset {
938 item_ix,
939 offset_in_item: old_scroll_top.offset_in_item,
940 })
941 .or_else(|| {
942 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
943 let item_ix = self
944 .entries
945 .iter()
946 .position(|entry| entry == entry_after_old_top)?;
947 Some(ListOffset {
948 item_ix,
949 offset_in_item: 0.,
950 })
951 })
952 .or_else(|| {
953 let entry_before_old_top =
954 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
955 let item_ix = self
956 .entries
957 .iter()
958 .position(|entry| entry == entry_before_old_top)?;
959 Some(ListOffset {
960 item_ix,
961 offset_in_item: 0.,
962 })
963 });
964
965 self.list_state
966 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
967 }
968
969 cx.notify();
970 }
971
972 fn render_call_participant(
973 user: &User,
974 is_pending: bool,
975 is_selected: bool,
976 theme: &theme::CollabPanel,
977 ) -> AnyElement<Self> {
978 Flex::row()
979 .with_children(user.avatar.clone().map(|avatar| {
980 Image::from_data(avatar)
981 .with_style(theme.contact_avatar)
982 .aligned()
983 .left()
984 }))
985 .with_child(
986 Label::new(
987 user.github_login.clone(),
988 theme.contact_username.text.clone(),
989 )
990 .contained()
991 .with_style(theme.contact_username.container)
992 .aligned()
993 .left()
994 .flex(1., true),
995 )
996 .with_children(if is_pending {
997 Some(
998 Label::new("Calling", theme.calling_indicator.text.clone())
999 .contained()
1000 .with_style(theme.calling_indicator.container)
1001 .aligned(),
1002 )
1003 } else {
1004 None
1005 })
1006 .constrained()
1007 .with_height(theme.row_height)
1008 .contained()
1009 .with_style(
1010 *theme
1011 .contact_row
1012 .in_state(is_selected)
1013 .style_for(&mut Default::default()),
1014 )
1015 .into_any()
1016 }
1017
1018 fn render_participant_project(
1019 project_id: u64,
1020 worktree_root_names: &[String],
1021 host_user_id: u64,
1022 is_current: bool,
1023 is_last: bool,
1024 is_selected: bool,
1025 theme: &theme::CollabPanel,
1026 cx: &mut ViewContext<Self>,
1027 ) -> AnyElement<Self> {
1028 enum JoinProject {}
1029
1030 let host_avatar_width = theme
1031 .contact_avatar
1032 .width
1033 .or(theme.contact_avatar.height)
1034 .unwrap_or(0.);
1035 let tree_branch = theme.tree_branch;
1036 let project_name = if worktree_root_names.is_empty() {
1037 "untitled".to_string()
1038 } else {
1039 worktree_root_names.join(", ")
1040 };
1041
1042 MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, cx| {
1043 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1044 let row = theme
1045 .project_row
1046 .in_state(is_selected)
1047 .style_for(mouse_state);
1048
1049 Flex::row()
1050 .with_child(render_tree_branch(
1051 tree_branch,
1052 &row.name.text,
1053 is_last,
1054 vec2f(host_avatar_width, theme.row_height),
1055 cx.font_cache(),
1056 ))
1057 .with_child(
1058 Svg::new("icons/file_icons/folder.svg")
1059 .with_color(theme.channel_hash.color)
1060 .constrained()
1061 .with_width(theme.channel_hash.width)
1062 .aligned()
1063 .left(),
1064 )
1065 .with_child(
1066 Label::new(project_name, row.name.text.clone())
1067 .aligned()
1068 .left()
1069 .contained()
1070 .with_style(row.name.container)
1071 .flex(1., false),
1072 )
1073 .constrained()
1074 .with_height(theme.row_height)
1075 .contained()
1076 .with_style(row.container)
1077 })
1078 .with_cursor_style(if !is_current {
1079 CursorStyle::PointingHand
1080 } else {
1081 CursorStyle::Arrow
1082 })
1083 .on_click(MouseButton::Left, move |_, this, cx| {
1084 if !is_current {
1085 if let Some(workspace) = this.workspace.upgrade(cx) {
1086 let app_state = workspace.read(cx).app_state().clone();
1087 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
1088 .detach_and_log_err(cx);
1089 }
1090 }
1091 })
1092 .into_any()
1093 }
1094
1095 fn render_participant_screen(
1096 peer_id: PeerId,
1097 is_last: bool,
1098 is_selected: bool,
1099 theme: &theme::CollabPanel,
1100 cx: &mut ViewContext<Self>,
1101 ) -> AnyElement<Self> {
1102 enum OpenSharedScreen {}
1103
1104 let font_cache = cx.font_cache();
1105 let host_avatar_height = theme
1106 .contact_avatar
1107 .width
1108 .or(theme.contact_avatar.height)
1109 .unwrap_or(0.);
1110 let row = &theme.project_row.inactive_state().default;
1111 let tree_branch = theme.tree_branch;
1112 let line_height = row.name.text.line_height(font_cache);
1113 let cap_height = row.name.text.cap_height(font_cache);
1114 let baseline_offset =
1115 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
1116
1117 MouseEventHandler::new::<OpenSharedScreen, _>(
1118 peer_id.as_u64() as usize,
1119 cx,
1120 |mouse_state, _| {
1121 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1122 let row = theme
1123 .project_row
1124 .in_state(is_selected)
1125 .style_for(mouse_state);
1126
1127 Flex::row()
1128 .with_child(
1129 Stack::new()
1130 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
1131 let start_x = bounds.min_x() + (bounds.width() / 2.)
1132 - (tree_branch.width / 2.);
1133 let end_x = bounds.max_x();
1134 let start_y = bounds.min_y();
1135 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
1136
1137 scene.push_quad(gpui::Quad {
1138 bounds: RectF::from_points(
1139 vec2f(start_x, start_y),
1140 vec2f(
1141 start_x + tree_branch.width,
1142 if is_last { end_y } else { bounds.max_y() },
1143 ),
1144 ),
1145 background: Some(tree_branch.color),
1146 border: gpui::Border::default(),
1147 corner_radii: (0.).into(),
1148 });
1149 scene.push_quad(gpui::Quad {
1150 bounds: RectF::from_points(
1151 vec2f(start_x, end_y),
1152 vec2f(end_x, end_y + tree_branch.width),
1153 ),
1154 background: Some(tree_branch.color),
1155 border: gpui::Border::default(),
1156 corner_radii: (0.).into(),
1157 });
1158 }))
1159 .constrained()
1160 .with_width(host_avatar_height),
1161 )
1162 .with_child(
1163 Svg::new("icons/disable_screen_sharing_12.svg")
1164 .with_color(row.icon.color)
1165 .constrained()
1166 .with_width(row.icon.width)
1167 .aligned()
1168 .left()
1169 .contained()
1170 .with_style(row.icon.container),
1171 )
1172 .with_child(
1173 Label::new("Screen", row.name.text.clone())
1174 .aligned()
1175 .left()
1176 .contained()
1177 .with_style(row.name.container)
1178 .flex(1., false),
1179 )
1180 .constrained()
1181 .with_height(theme.row_height)
1182 .contained()
1183 .with_style(row.container)
1184 },
1185 )
1186 .with_cursor_style(CursorStyle::PointingHand)
1187 .on_click(MouseButton::Left, move |_, this, cx| {
1188 if let Some(workspace) = this.workspace.upgrade(cx) {
1189 workspace.update(cx, |workspace, cx| {
1190 workspace.open_shared_screen(peer_id, cx)
1191 });
1192 }
1193 })
1194 .into_any()
1195 }
1196
1197 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
1198 if let Some(_) = self.channel_editing_state.take() {
1199 self.channel_name_editor.update(cx, |editor, cx| {
1200 editor.set_text("", cx);
1201 });
1202 true
1203 } else {
1204 false
1205 }
1206 }
1207
1208 fn render_header(
1209 &self,
1210 section: Section,
1211 theme: &theme::Theme,
1212 depth: usize,
1213 is_selected: bool,
1214 is_collapsed: bool,
1215 cx: &mut ViewContext<Self>,
1216 ) -> AnyElement<Self> {
1217 enum Header {}
1218 enum LeaveCallContactList {}
1219 enum AddChannel {}
1220
1221 let tooltip_style = &theme.tooltip;
1222 let text = match section {
1223 Section::ActiveCall => {
1224 let channel_name = iife!({
1225 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1226
1227 let name = self
1228 .channel_store
1229 .read(cx)
1230 .channel_for_id(channel_id)?
1231 .name
1232 .as_str();
1233
1234 Some(name)
1235 });
1236
1237 if let Some(name) = channel_name {
1238 Cow::Owned(format!("#{}", name))
1239 } else {
1240 Cow::Borrowed("Current Call")
1241 }
1242 }
1243 Section::ContactRequests => Cow::Borrowed("Requests"),
1244 Section::Contacts => Cow::Borrowed("Contacts"),
1245 Section::Channels => Cow::Borrowed("Channels"),
1246 Section::ChannelInvites => Cow::Borrowed("Invites"),
1247 Section::Online => Cow::Borrowed("Online"),
1248 Section::Offline => Cow::Borrowed("Offline"),
1249 };
1250
1251 enum AddContact {}
1252 let button = match section {
1253 Section::ActiveCall => Some(
1254 MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
1255 render_icon_button(
1256 theme
1257 .collab_panel
1258 .leave_call_button
1259 .style_for(is_selected, state),
1260 "icons/exit.svg",
1261 )
1262 })
1263 .with_cursor_style(CursorStyle::PointingHand)
1264 .on_click(MouseButton::Left, |_, _, cx| {
1265 Self::leave_call(cx);
1266 })
1267 .with_tooltip::<AddContact>(
1268 0,
1269 "Leave call",
1270 None,
1271 tooltip_style.clone(),
1272 cx,
1273 ),
1274 ),
1275 Section::Contacts => Some(
1276 MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
1277 render_icon_button(
1278 theme
1279 .collab_panel
1280 .add_contact_button
1281 .style_for(is_selected, state),
1282 "icons/plus_16.svg",
1283 )
1284 })
1285 .with_cursor_style(CursorStyle::PointingHand)
1286 .on_click(MouseButton::Left, |_, this, cx| {
1287 this.toggle_contact_finder(cx);
1288 })
1289 .with_tooltip::<LeaveCallContactList>(
1290 0,
1291 "Search for new contact",
1292 None,
1293 tooltip_style.clone(),
1294 cx,
1295 ),
1296 ),
1297 Section::Channels => Some(
1298 MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
1299 render_icon_button(
1300 theme
1301 .collab_panel
1302 .add_contact_button
1303 .style_for(is_selected, state),
1304 "icons/plus.svg",
1305 )
1306 })
1307 .with_cursor_style(CursorStyle::PointingHand)
1308 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
1309 .with_tooltip::<AddChannel>(
1310 0,
1311 "Create a channel",
1312 None,
1313 tooltip_style.clone(),
1314 cx,
1315 ),
1316 ),
1317 _ => None,
1318 };
1319
1320 let can_collapse = depth > 0;
1321 let icon_size = (&theme.collab_panel).section_icon_size;
1322 let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
1323 let header_style = if can_collapse {
1324 theme
1325 .collab_panel
1326 .subheader_row
1327 .in_state(is_selected)
1328 .style_for(state)
1329 } else {
1330 &theme.collab_panel.header_row
1331 };
1332
1333 Flex::row()
1334 .with_children(if can_collapse {
1335 Some(
1336 Svg::new(if is_collapsed {
1337 "icons/chevron_right.svg"
1338 } else {
1339 "icons/chevron_down.svg"
1340 })
1341 .with_color(header_style.text.color)
1342 .constrained()
1343 .with_max_width(icon_size)
1344 .with_max_height(icon_size)
1345 .aligned()
1346 .constrained()
1347 .with_width(icon_size)
1348 .contained()
1349 .with_margin_right(
1350 theme.collab_panel.contact_username.container.margin.left,
1351 ),
1352 )
1353 } else {
1354 None
1355 })
1356 .with_child(
1357 Label::new(text, header_style.text.clone())
1358 .aligned()
1359 .left()
1360 .flex(1., true),
1361 )
1362 .with_children(button.map(|button| button.aligned().right()))
1363 .constrained()
1364 .with_height(theme.collab_panel.row_height)
1365 .contained()
1366 .with_style(header_style.container)
1367 });
1368
1369 if can_collapse {
1370 result = result
1371 .with_cursor_style(CursorStyle::PointingHand)
1372 .on_click(MouseButton::Left, move |_, this, cx| {
1373 if can_collapse {
1374 this.toggle_section_expanded(section, cx);
1375 }
1376 })
1377 }
1378
1379 result.into_any()
1380 }
1381
1382 fn render_contact(
1383 contact: &Contact,
1384 calling: bool,
1385 project: &ModelHandle<Project>,
1386 theme: &theme::CollabPanel,
1387 is_selected: bool,
1388 cx: &mut ViewContext<Self>,
1389 ) -> AnyElement<Self> {
1390 let online = contact.online;
1391 let busy = contact.busy || calling;
1392 let user_id = contact.user.id;
1393 let github_login = contact.user.github_login.clone();
1394 let initial_project = project.clone();
1395 let mut event_handler =
1396 MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
1397 Flex::row()
1398 .with_children(contact.user.avatar.clone().map(|avatar| {
1399 let status_badge = if contact.online {
1400 Some(
1401 Empty::new()
1402 .collapsed()
1403 .contained()
1404 .with_style(if busy {
1405 theme.contact_status_busy
1406 } else {
1407 theme.contact_status_free
1408 })
1409 .aligned(),
1410 )
1411 } else {
1412 None
1413 };
1414 Stack::new()
1415 .with_child(
1416 Image::from_data(avatar)
1417 .with_style(theme.contact_avatar)
1418 .aligned()
1419 .left(),
1420 )
1421 .with_children(status_badge)
1422 }))
1423 .with_child(
1424 Label::new(
1425 contact.user.github_login.clone(),
1426 theme.contact_username.text.clone(),
1427 )
1428 .contained()
1429 .with_style(theme.contact_username.container)
1430 .aligned()
1431 .left()
1432 .flex(1., true),
1433 )
1434 .with_child(
1435 MouseEventHandler::new::<Cancel, _>(
1436 contact.user.id as usize,
1437 cx,
1438 |mouse_state, _| {
1439 let button_style = theme.contact_button.style_for(mouse_state);
1440 render_icon_button(button_style, "icons/x.svg")
1441 .aligned()
1442 .flex_float()
1443 },
1444 )
1445 .with_padding(Padding::uniform(2.))
1446 .with_cursor_style(CursorStyle::PointingHand)
1447 .on_click(MouseButton::Left, move |_, this, cx| {
1448 this.remove_contact(user_id, &github_login, cx);
1449 })
1450 .flex_float(),
1451 )
1452 .with_children(if calling {
1453 Some(
1454 Label::new("Calling", theme.calling_indicator.text.clone())
1455 .contained()
1456 .with_style(theme.calling_indicator.container)
1457 .aligned(),
1458 )
1459 } else {
1460 None
1461 })
1462 .constrained()
1463 .with_height(theme.row_height)
1464 .contained()
1465 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1466 })
1467 .on_click(MouseButton::Left, move |_, this, cx| {
1468 if online && !busy {
1469 this.call(user_id, Some(initial_project.clone()), cx);
1470 }
1471 });
1472
1473 if online {
1474 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1475 }
1476
1477 event_handler.into_any()
1478 }
1479
1480 fn render_contact_placeholder(
1481 &self,
1482 theme: &theme::CollabPanel,
1483 is_selected: bool,
1484 cx: &mut ViewContext<Self>,
1485 ) -> AnyElement<Self> {
1486 enum AddContacts {}
1487 MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
1488 let style = theme.list_empty_state.style_for(is_selected, state);
1489 Flex::row()
1490 .with_child(
1491 Svg::new("icons/plus.svg")
1492 .with_color(theme.list_empty_icon.color)
1493 .constrained()
1494 .with_width(theme.list_empty_icon.width)
1495 .aligned()
1496 .left(),
1497 )
1498 .with_child(
1499 Label::new("Add a contact", style.text.clone())
1500 .contained()
1501 .with_style(theme.list_empty_label_container),
1502 )
1503 .align_children_center()
1504 .contained()
1505 .with_style(style.container)
1506 .into_any()
1507 })
1508 .on_click(MouseButton::Left, |_, this, cx| {
1509 this.toggle_contact_finder(cx);
1510 })
1511 .into_any()
1512 }
1513
1514 fn render_channel_editor(
1515 &self,
1516 theme: &theme::Theme,
1517 depth: usize,
1518 cx: &AppContext,
1519 ) -> AnyElement<Self> {
1520 Flex::row()
1521 .with_child(
1522 Empty::new()
1523 .constrained()
1524 .with_width(theme.collab_panel.disclosure.button_space()),
1525 )
1526 .with_child(
1527 Svg::new("icons/hash.svg")
1528 .with_color(theme.collab_panel.channel_hash.color)
1529 .constrained()
1530 .with_width(theme.collab_panel.channel_hash.width)
1531 .aligned()
1532 .left(),
1533 )
1534 .with_child(
1535 if let Some(pending_name) = self
1536 .channel_editing_state
1537 .as_ref()
1538 .and_then(|state| state.pending_name())
1539 {
1540 Label::new(
1541 pending_name.to_string(),
1542 theme.collab_panel.contact_username.text.clone(),
1543 )
1544 .contained()
1545 .with_style(theme.collab_panel.contact_username.container)
1546 .aligned()
1547 .left()
1548 .flex(1., true)
1549 .into_any()
1550 } else {
1551 ChildView::new(&self.channel_name_editor, cx)
1552 .aligned()
1553 .left()
1554 .contained()
1555 .with_style(theme.collab_panel.channel_editor)
1556 .flex(1.0, true)
1557 .into_any()
1558 },
1559 )
1560 .align_children_center()
1561 .constrained()
1562 .with_height(theme.collab_panel.row_height)
1563 .contained()
1564 .with_style(gpui::elements::ContainerStyle {
1565 background_color: Some(theme.editor.background),
1566 ..*theme.collab_panel.contact_row.default_style()
1567 })
1568 .with_padding_left(
1569 theme.collab_panel.contact_row.default_style().padding.left
1570 + theme.collab_panel.channel_indent * depth as f32,
1571 )
1572 .into_any()
1573 }
1574
1575 fn render_channel(
1576 &self,
1577 channel: &Channel,
1578 depth: usize,
1579 theme: &theme::CollabPanel,
1580 is_selected: bool,
1581 cx: &mut ViewContext<Self>,
1582 ) -> AnyElement<Self> {
1583 let channel_id = channel.id;
1584 let has_children = self.channel_store.read(cx).has_children(channel_id);
1585 let disclosed =
1586 has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok());
1587
1588 let is_active = iife!({
1589 let call_channel = ActiveCall::global(cx)
1590 .read(cx)
1591 .room()?
1592 .read(cx)
1593 .channel_id()?;
1594 Some(call_channel == channel_id)
1595 })
1596 .unwrap_or(false);
1597
1598 const FACEPILE_LIMIT: usize = 3;
1599
1600 MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
1601 Flex::<Self>::row()
1602 .with_child(
1603 Svg::new("icons/hash.svg")
1604 .with_color(theme.channel_hash.color)
1605 .constrained()
1606 .with_width(theme.channel_hash.width)
1607 .aligned()
1608 .left(),
1609 )
1610 .with_child(
1611 Label::new(channel.name.clone(), theme.channel_name.text.clone())
1612 .contained()
1613 .with_style(theme.channel_name.container)
1614 .aligned()
1615 .left()
1616 .flex(1., true),
1617 )
1618 .with_children({
1619 let participants = self.channel_store.read(cx).channel_participants(channel_id);
1620 if !participants.is_empty() {
1621 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
1622
1623 Some(
1624 FacePile::new(theme.face_overlap)
1625 .with_children(
1626 participants
1627 .iter()
1628 .filter_map(|user| {
1629 Some(
1630 Image::from_data(user.avatar.clone()?)
1631 .with_style(theme.channel_avatar),
1632 )
1633 })
1634 .take(FACEPILE_LIMIT),
1635 )
1636 .with_children((extra_count > 0).then(|| {
1637 Label::new(
1638 format!("+{}", extra_count),
1639 theme.extra_participant_label.text.clone(),
1640 )
1641 .contained()
1642 .with_style(theme.extra_participant_label.container)
1643 })),
1644 )
1645 } else {
1646 None
1647 }
1648 })
1649 .align_children_center()
1650 .styleable_component()
1651 .disclosable(disclosed, Box::new(ToggleCollapse { channel_id }))
1652 .with_id(channel_id as usize)
1653 .with_style(theme.disclosure.clone())
1654 .element()
1655 .constrained()
1656 .with_height(theme.row_height)
1657 .contained()
1658 .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
1659 .with_padding_left(
1660 theme.channel_row.default_style().padding.left
1661 + theme.channel_indent * depth as f32,
1662 )
1663 })
1664 .on_click(MouseButton::Left, move |_, this, cx| {
1665 this.join_channel(channel_id, cx);
1666 })
1667 .on_click(MouseButton::Right, move |e, this, cx| {
1668 this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
1669 })
1670 .with_cursor_style(CursorStyle::PointingHand)
1671 .into_any()
1672 }
1673
1674 fn render_channel_notes(
1675 &self,
1676 channel_id: ChannelId,
1677 theme: &theme::CollabPanel,
1678 is_selected: bool,
1679 cx: &mut ViewContext<Self>,
1680 ) -> AnyElement<Self> {
1681 enum ChannelNotes {}
1682 let host_avatar_width = theme
1683 .contact_avatar
1684 .width
1685 .or(theme.contact_avatar.height)
1686 .unwrap_or(0.);
1687
1688 MouseEventHandler::new::<ChannelNotes, _>(channel_id as usize, cx, |state, cx| {
1689 let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
1690 let row = theme.project_row.in_state(is_selected).style_for(state);
1691
1692 Flex::<Self>::row()
1693 .with_child(render_tree_branch(
1694 tree_branch,
1695 &row.name.text,
1696 true,
1697 vec2f(host_avatar_width, theme.row_height),
1698 cx.font_cache(),
1699 ))
1700 .with_child(
1701 Svg::new("icons/radix/file.svg")
1702 .with_color(theme.channel_hash.color)
1703 .constrained()
1704 .with_width(theme.channel_hash.width)
1705 .aligned()
1706 .left(),
1707 )
1708 .with_child(
1709 Label::new("notes", theme.channel_name.text.clone())
1710 .contained()
1711 .with_style(theme.channel_name.container)
1712 .aligned()
1713 .left()
1714 .flex(1., true),
1715 )
1716 .constrained()
1717 .with_height(theme.row_height)
1718 .contained()
1719 .with_style(*theme.channel_row.style_for(is_selected, state))
1720 .with_padding_left(theme.channel_row.default_style().padding.left)
1721 })
1722 .on_click(MouseButton::Left, move |_, this, cx| {
1723 this.open_channel_buffer(&OpenChannelBuffer { channel_id }, cx);
1724 })
1725 .with_cursor_style(CursorStyle::PointingHand)
1726 .into_any()
1727 }
1728
1729 fn render_channel_invite(
1730 channel: Arc<Channel>,
1731 channel_store: ModelHandle<ChannelStore>,
1732 theme: &theme::CollabPanel,
1733 is_selected: bool,
1734 cx: &mut ViewContext<Self>,
1735 ) -> AnyElement<Self> {
1736 enum Decline {}
1737 enum Accept {}
1738
1739 let channel_id = channel.id;
1740 let is_invite_pending = channel_store
1741 .read(cx)
1742 .has_pending_channel_invite_response(&channel);
1743 let button_spacing = theme.contact_button_spacing;
1744
1745 Flex::row()
1746 .with_child(
1747 Svg::new("icons/hash.svg")
1748 .with_color(theme.channel_hash.color)
1749 .constrained()
1750 .with_width(theme.channel_hash.width)
1751 .aligned()
1752 .left(),
1753 )
1754 .with_child(
1755 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1756 .contained()
1757 .with_style(theme.contact_username.container)
1758 .aligned()
1759 .left()
1760 .flex(1., true),
1761 )
1762 .with_child(
1763 MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
1764 let button_style = if is_invite_pending {
1765 &theme.disabled_button
1766 } else {
1767 theme.contact_button.style_for(mouse_state)
1768 };
1769 render_icon_button(button_style, "icons/x.svg").aligned()
1770 })
1771 .with_cursor_style(CursorStyle::PointingHand)
1772 .on_click(MouseButton::Left, move |_, this, cx| {
1773 this.respond_to_channel_invite(channel_id, false, cx);
1774 })
1775 .contained()
1776 .with_margin_right(button_spacing),
1777 )
1778 .with_child(
1779 MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
1780 let button_style = if is_invite_pending {
1781 &theme.disabled_button
1782 } else {
1783 theme.contact_button.style_for(mouse_state)
1784 };
1785 render_icon_button(button_style, "icons/check.svg")
1786 .aligned()
1787 .flex_float()
1788 })
1789 .with_cursor_style(CursorStyle::PointingHand)
1790 .on_click(MouseButton::Left, move |_, this, cx| {
1791 this.respond_to_channel_invite(channel_id, true, cx);
1792 }),
1793 )
1794 .constrained()
1795 .with_height(theme.row_height)
1796 .contained()
1797 .with_style(
1798 *theme
1799 .contact_row
1800 .in_state(is_selected)
1801 .style_for(&mut Default::default()),
1802 )
1803 .with_padding_left(
1804 theme.contact_row.default_style().padding.left + theme.channel_indent,
1805 )
1806 .into_any()
1807 }
1808
1809 fn render_contact_request(
1810 user: Arc<User>,
1811 user_store: ModelHandle<UserStore>,
1812 theme: &theme::CollabPanel,
1813 is_incoming: bool,
1814 is_selected: bool,
1815 cx: &mut ViewContext<Self>,
1816 ) -> AnyElement<Self> {
1817 enum Decline {}
1818 enum Accept {}
1819 enum Cancel {}
1820
1821 let mut row = Flex::row()
1822 .with_children(user.avatar.clone().map(|avatar| {
1823 Image::from_data(avatar)
1824 .with_style(theme.contact_avatar)
1825 .aligned()
1826 .left()
1827 }))
1828 .with_child(
1829 Label::new(
1830 user.github_login.clone(),
1831 theme.contact_username.text.clone(),
1832 )
1833 .contained()
1834 .with_style(theme.contact_username.container)
1835 .aligned()
1836 .left()
1837 .flex(1., true),
1838 );
1839
1840 let user_id = user.id;
1841 let github_login = user.github_login.clone();
1842 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1843 let button_spacing = theme.contact_button_spacing;
1844
1845 if is_incoming {
1846 row.add_child(
1847 MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
1848 let button_style = if is_contact_request_pending {
1849 &theme.disabled_button
1850 } else {
1851 theme.contact_button.style_for(mouse_state)
1852 };
1853 render_icon_button(button_style, "icons/x.svg").aligned()
1854 })
1855 .with_cursor_style(CursorStyle::PointingHand)
1856 .on_click(MouseButton::Left, move |_, this, cx| {
1857 this.respond_to_contact_request(user_id, false, cx);
1858 })
1859 .contained()
1860 .with_margin_right(button_spacing),
1861 );
1862
1863 row.add_child(
1864 MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
1865 let button_style = if is_contact_request_pending {
1866 &theme.disabled_button
1867 } else {
1868 theme.contact_button.style_for(mouse_state)
1869 };
1870 render_icon_button(button_style, "icons/check.svg")
1871 .aligned()
1872 .flex_float()
1873 })
1874 .with_cursor_style(CursorStyle::PointingHand)
1875 .on_click(MouseButton::Left, move |_, this, cx| {
1876 this.respond_to_contact_request(user_id, true, cx);
1877 }),
1878 );
1879 } else {
1880 row.add_child(
1881 MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
1882 let button_style = if is_contact_request_pending {
1883 &theme.disabled_button
1884 } else {
1885 theme.contact_button.style_for(mouse_state)
1886 };
1887 render_icon_button(button_style, "icons/x.svg")
1888 .aligned()
1889 .flex_float()
1890 })
1891 .with_padding(Padding::uniform(2.))
1892 .with_cursor_style(CursorStyle::PointingHand)
1893 .on_click(MouseButton::Left, move |_, this, cx| {
1894 this.remove_contact(user_id, &github_login, cx);
1895 })
1896 .flex_float(),
1897 );
1898 }
1899
1900 row.constrained()
1901 .with_height(theme.row_height)
1902 .contained()
1903 .with_style(
1904 *theme
1905 .contact_row
1906 .in_state(is_selected)
1907 .style_for(&mut Default::default()),
1908 )
1909 .into_any()
1910 }
1911
1912 fn include_channels_section(&self, cx: &AppContext) -> bool {
1913 if cx.has_global::<StaffMode>() {
1914 cx.global::<StaffMode>().0
1915 } else {
1916 false
1917 }
1918 }
1919
1920 fn deploy_channel_context_menu(
1921 &mut self,
1922 position: Option<Vector2F>,
1923 channel_id: u64,
1924 cx: &mut ViewContext<Self>,
1925 ) {
1926 self.context_menu_on_selected = position.is_none();
1927
1928 self.context_menu.update(cx, |context_menu, cx| {
1929 context_menu.set_position_mode(if self.context_menu_on_selected {
1930 OverlayPositionMode::Local
1931 } else {
1932 OverlayPositionMode::Window
1933 });
1934
1935 let expand_action_name = if self.is_channel_collapsed(channel_id) {
1936 "Expand Subchannels"
1937 } else {
1938 "Collapse Subchannels"
1939 };
1940
1941 let mut items = vec![
1942 ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }),
1943 ContextMenuItem::action("Open Notes", OpenChannelBuffer { channel_id }),
1944 ];
1945
1946 if self.channel_store.read(cx).is_user_admin(channel_id) {
1947 items.extend([
1948 ContextMenuItem::Separator,
1949 ContextMenuItem::action("New Subchannel", NewChannel { channel_id }),
1950 ContextMenuItem::action("Rename", RenameChannel { channel_id }),
1951 ContextMenuItem::Separator,
1952 ContextMenuItem::action("Invite Members", InviteMembers { channel_id }),
1953 ContextMenuItem::action("Manage Members", ManageMembers { channel_id }),
1954 ContextMenuItem::Separator,
1955 ContextMenuItem::action("Delete", RemoveChannel { channel_id }),
1956 ]);
1957 }
1958
1959 context_menu.show(
1960 position.unwrap_or_default(),
1961 if self.context_menu_on_selected {
1962 gpui::elements::AnchorCorner::TopRight
1963 } else {
1964 gpui::elements::AnchorCorner::BottomLeft
1965 },
1966 items,
1967 cx,
1968 );
1969 });
1970
1971 cx.notify();
1972 }
1973
1974 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1975 if self.take_editing_state(cx) {
1976 cx.focus(&self.filter_editor);
1977 } else {
1978 self.filter_editor.update(cx, |editor, cx| {
1979 if editor.buffer().read(cx).len(cx) > 0 {
1980 editor.set_text("", cx);
1981 }
1982 });
1983 }
1984
1985 self.update_entries(false, cx);
1986 }
1987
1988 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1989 let ix = self.selection.map_or(0, |ix| ix + 1);
1990 if ix < self.entries.len() {
1991 self.selection = Some(ix);
1992 }
1993
1994 self.list_state.reset(self.entries.len());
1995 if let Some(ix) = self.selection {
1996 self.list_state.scroll_to(ListOffset {
1997 item_ix: ix,
1998 offset_in_item: 0.,
1999 });
2000 }
2001 cx.notify();
2002 }
2003
2004 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
2005 let ix = self.selection.take().unwrap_or(0);
2006 if ix > 0 {
2007 self.selection = Some(ix - 1);
2008 }
2009
2010 self.list_state.reset(self.entries.len());
2011 if let Some(ix) = self.selection {
2012 self.list_state.scroll_to(ListOffset {
2013 item_ix: ix,
2014 offset_in_item: 0.,
2015 });
2016 }
2017 cx.notify();
2018 }
2019
2020 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
2021 if self.confirm_channel_edit(cx) {
2022 return;
2023 }
2024
2025 if let Some(selection) = self.selection {
2026 if let Some(entry) = self.entries.get(selection) {
2027 match entry {
2028 ListEntry::Header(section, _) => match section {
2029 Section::ActiveCall => Self::leave_call(cx),
2030 Section::Channels => self.new_root_channel(cx),
2031 Section::Contacts => self.toggle_contact_finder(cx),
2032 Section::ContactRequests
2033 | Section::Online
2034 | Section::Offline
2035 | Section::ChannelInvites => {
2036 self.toggle_section_expanded(*section, cx);
2037 }
2038 },
2039 ListEntry::Contact { contact, calling } => {
2040 if contact.online && !contact.busy && !calling {
2041 self.call(contact.user.id, Some(self.project.clone()), cx);
2042 }
2043 }
2044 ListEntry::ParticipantProject {
2045 project_id,
2046 host_user_id,
2047 ..
2048 } => {
2049 if let Some(workspace) = self.workspace.upgrade(cx) {
2050 let app_state = workspace.read(cx).app_state().clone();
2051 workspace::join_remote_project(
2052 *project_id,
2053 *host_user_id,
2054 app_state,
2055 cx,
2056 )
2057 .detach_and_log_err(cx);
2058 }
2059 }
2060 ListEntry::ParticipantScreen { peer_id, .. } => {
2061 if let Some(workspace) = self.workspace.upgrade(cx) {
2062 workspace.update(cx, |workspace, cx| {
2063 workspace.open_shared_screen(*peer_id, cx)
2064 });
2065 }
2066 }
2067 ListEntry::Channel { channel, .. } => {
2068 self.join_channel(channel.id, cx);
2069 }
2070 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
2071 _ => {}
2072 }
2073 }
2074 }
2075 }
2076
2077 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
2078 if let Some(editing_state) = &mut self.channel_editing_state {
2079 match editing_state {
2080 ChannelEditingState::Create {
2081 parent_id,
2082 pending_name,
2083 ..
2084 } => {
2085 if pending_name.is_some() {
2086 return false;
2087 }
2088 let channel_name = self.channel_name_editor.read(cx).text(cx);
2089
2090 *pending_name = Some(channel_name.clone());
2091
2092 self.channel_store
2093 .update(cx, |channel_store, cx| {
2094 channel_store.create_channel(&channel_name, *parent_id, cx)
2095 })
2096 .detach();
2097 cx.notify();
2098 }
2099 ChannelEditingState::Rename {
2100 channel_id,
2101 pending_name,
2102 } => {
2103 if pending_name.is_some() {
2104 return false;
2105 }
2106 let channel_name = self.channel_name_editor.read(cx).text(cx);
2107 *pending_name = Some(channel_name.clone());
2108
2109 self.channel_store
2110 .update(cx, |channel_store, cx| {
2111 channel_store.rename(*channel_id, &channel_name, cx)
2112 })
2113 .detach();
2114 cx.notify();
2115 }
2116 }
2117 cx.focus_self();
2118 true
2119 } else {
2120 false
2121 }
2122 }
2123
2124 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
2125 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
2126 self.collapsed_sections.remove(ix);
2127 } else {
2128 self.collapsed_sections.push(section);
2129 }
2130 self.update_entries(false, cx);
2131 }
2132
2133 fn collapse_selected_channel(
2134 &mut self,
2135 _: &CollapseSelectedChannel,
2136 cx: &mut ViewContext<Self>,
2137 ) {
2138 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
2139 return;
2140 };
2141
2142 if self.is_channel_collapsed(channel_id) {
2143 return;
2144 }
2145
2146 self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
2147 }
2148
2149 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
2150 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
2151 return;
2152 };
2153
2154 if !self.is_channel_collapsed(channel_id) {
2155 return;
2156 }
2157
2158 self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
2159 }
2160
2161 fn toggle_channel_collapsed(&mut self, action: &ToggleCollapse, cx: &mut ViewContext<Self>) {
2162 let channel_id = action.channel_id;
2163
2164 match self.collapsed_channels.binary_search(&channel_id) {
2165 Ok(ix) => {
2166 self.collapsed_channels.remove(ix);
2167 }
2168 Err(ix) => {
2169 self.collapsed_channels.insert(ix, channel_id);
2170 }
2171 };
2172 self.serialize(cx);
2173 self.update_entries(true, cx);
2174 cx.notify();
2175 cx.focus_self();
2176 }
2177
2178 fn is_channel_collapsed(&self, channel: ChannelId) -> bool {
2179 self.collapsed_channels.binary_search(&channel).is_ok()
2180 }
2181
2182 fn leave_call(cx: &mut ViewContext<Self>) {
2183 ActiveCall::global(cx)
2184 .update(cx, |call, cx| call.hang_up(cx))
2185 .detach_and_log_err(cx);
2186 }
2187
2188 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
2189 if let Some(workspace) = self.workspace.upgrade(cx) {
2190 workspace.update(cx, |workspace, cx| {
2191 workspace.toggle_modal(cx, |_, cx| {
2192 cx.add_view(|cx| {
2193 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
2194 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
2195 finder
2196 })
2197 });
2198 });
2199 }
2200 }
2201
2202 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
2203 self.channel_editing_state = Some(ChannelEditingState::Create {
2204 parent_id: None,
2205 pending_name: None,
2206 });
2207 self.update_entries(false, cx);
2208 self.select_channel_editor();
2209 cx.focus(self.channel_name_editor.as_any());
2210 cx.notify();
2211 }
2212
2213 fn select_channel_editor(&mut self) {
2214 self.selection = self.entries.iter().position(|entry| match entry {
2215 ListEntry::ChannelEditor { .. } => true,
2216 _ => false,
2217 });
2218 }
2219
2220 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
2221 self.collapsed_channels
2222 .retain(|&channel| channel != action.channel_id);
2223 self.channel_editing_state = Some(ChannelEditingState::Create {
2224 parent_id: Some(action.channel_id),
2225 pending_name: None,
2226 });
2227 self.update_entries(false, cx);
2228 self.select_channel_editor();
2229 cx.focus(self.channel_name_editor.as_any());
2230 cx.notify();
2231 }
2232
2233 fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
2234 self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
2235 }
2236
2237 fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
2238 self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
2239 }
2240
2241 fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
2242 if let Some(channel) = self.selected_channel() {
2243 self.remove_channel(channel.id, cx)
2244 }
2245 }
2246
2247 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
2248 if let Some(channel) = self.selected_channel() {
2249 self.rename_channel(
2250 &RenameChannel {
2251 channel_id: channel.id,
2252 },
2253 cx,
2254 );
2255 }
2256 }
2257
2258 fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
2259 let channel_store = self.channel_store.read(cx);
2260 if !channel_store.is_user_admin(action.channel_id) {
2261 return;
2262 }
2263 if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
2264 self.channel_editing_state = Some(ChannelEditingState::Rename {
2265 channel_id: action.channel_id,
2266 pending_name: None,
2267 });
2268 self.channel_name_editor.update(cx, |editor, cx| {
2269 editor.set_text(channel.name.clone(), cx);
2270 editor.select_all(&Default::default(), cx);
2271 });
2272 cx.focus(self.channel_name_editor.as_any());
2273 self.update_entries(false, cx);
2274 self.select_channel_editor();
2275 }
2276 }
2277
2278 fn open_channel_buffer(&mut self, action: &OpenChannelBuffer, cx: &mut ViewContext<Self>) {
2279 if let Some(workspace) = self.workspace.upgrade(cx) {
2280 let pane = workspace.read(cx).active_pane().clone();
2281 let channel_view = ChannelView::open(action.channel_id, pane.clone(), workspace, cx);
2282 cx.spawn(|_, mut cx| async move {
2283 let channel_view = channel_view.await?;
2284 pane.update(&mut cx, |pane, cx| {
2285 pane.add_item(Box::new(channel_view), true, true, None, cx)
2286 });
2287 anyhow::Ok(())
2288 })
2289 .detach();
2290 }
2291 }
2292
2293 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
2294 let Some(channel) = self.selected_channel() else {
2295 return;
2296 };
2297
2298 self.deploy_channel_context_menu(None, channel.id, cx);
2299 }
2300
2301 fn selected_channel(&self) -> Option<&Arc<Channel>> {
2302 self.selection
2303 .and_then(|ix| self.entries.get(ix))
2304 .and_then(|entry| match entry {
2305 ListEntry::Channel { channel, .. } => Some(channel),
2306 _ => None,
2307 })
2308 }
2309
2310 fn show_channel_modal(
2311 &mut self,
2312 channel_id: ChannelId,
2313 mode: channel_modal::Mode,
2314 cx: &mut ViewContext<Self>,
2315 ) {
2316 let workspace = self.workspace.clone();
2317 let user_store = self.user_store.clone();
2318 let channel_store = self.channel_store.clone();
2319 let members = self.channel_store.update(cx, |channel_store, cx| {
2320 channel_store.get_channel_member_details(channel_id, cx)
2321 });
2322
2323 cx.spawn(|_, mut cx| async move {
2324 let members = members.await?;
2325 workspace.update(&mut cx, |workspace, cx| {
2326 workspace.toggle_modal(cx, |_, cx| {
2327 cx.add_view(|cx| {
2328 ChannelModal::new(
2329 user_store.clone(),
2330 channel_store.clone(),
2331 channel_id,
2332 mode,
2333 members,
2334 cx,
2335 )
2336 })
2337 });
2338 })
2339 })
2340 .detach();
2341 }
2342
2343 fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
2344 self.remove_channel(action.channel_id, cx)
2345 }
2346
2347 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2348 let channel_store = self.channel_store.clone();
2349 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
2350 let prompt_message = format!(
2351 "Are you sure you want to remove the channel \"{}\"?",
2352 channel.name
2353 );
2354 let mut answer =
2355 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2356 let window = cx.window();
2357 cx.spawn(|this, mut cx| async move {
2358 if answer.next().await == Some(0) {
2359 if let Err(e) = channel_store
2360 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
2361 .await
2362 {
2363 window.prompt(
2364 PromptLevel::Info,
2365 &format!("Failed to remove channel: {}", e),
2366 &["Ok"],
2367 &mut cx,
2368 );
2369 }
2370 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
2371 }
2372 })
2373 .detach();
2374 }
2375 }
2376
2377 // Should move to the filter editor if clicking on it
2378 // Should move selection to the channel editor if activating it
2379
2380 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
2381 let user_store = self.user_store.clone();
2382 let prompt_message = format!(
2383 "Are you sure you want to remove \"{}\" from your contacts?",
2384 github_login
2385 );
2386 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
2387 let window = cx.window();
2388 cx.spawn(|_, mut cx| async move {
2389 if answer.next().await == Some(0) {
2390 if let Err(e) = user_store
2391 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
2392 .await
2393 {
2394 window.prompt(
2395 PromptLevel::Info,
2396 &format!("Failed to remove contact: {}", e),
2397 &["Ok"],
2398 &mut cx,
2399 );
2400 }
2401 }
2402 })
2403 .detach();
2404 }
2405
2406 fn respond_to_contact_request(
2407 &mut self,
2408 user_id: u64,
2409 accept: bool,
2410 cx: &mut ViewContext<Self>,
2411 ) {
2412 self.user_store
2413 .update(cx, |store, cx| {
2414 store.respond_to_contact_request(user_id, accept, cx)
2415 })
2416 .detach();
2417 }
2418
2419 fn respond_to_channel_invite(
2420 &mut self,
2421 channel_id: u64,
2422 accept: bool,
2423 cx: &mut ViewContext<Self>,
2424 ) {
2425 let respond = self.channel_store.update(cx, |store, _| {
2426 store.respond_to_channel_invite(channel_id, accept)
2427 });
2428 cx.foreground().spawn(respond).detach();
2429 }
2430
2431 fn call(
2432 &mut self,
2433 recipient_user_id: u64,
2434 initial_project: Option<ModelHandle<Project>>,
2435 cx: &mut ViewContext<Self>,
2436 ) {
2437 ActiveCall::global(cx)
2438 .update(cx, |call, cx| {
2439 call.invite(recipient_user_id, initial_project, cx)
2440 })
2441 .detach_and_log_err(cx);
2442 }
2443
2444 fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
2445 ActiveCall::global(cx)
2446 .update(cx, |call, cx| call.join_channel(channel, cx))
2447 .detach_and_log_err(cx);
2448 }
2449}
2450
2451fn render_tree_branch(
2452 branch_style: theme::TreeBranch,
2453 row_style: &TextStyle,
2454 is_last: bool,
2455 size: Vector2F,
2456 font_cache: &FontCache,
2457) -> gpui::elements::ConstrainedBox<CollabPanel> {
2458 let line_height = row_style.line_height(font_cache);
2459 let cap_height = row_style.cap_height(font_cache);
2460 let baseline_offset = row_style.baseline_offset(font_cache) + (size.y() - line_height) / 2.;
2461
2462 Canvas::new(move |scene, bounds, _, _, _| {
2463 scene.paint_layer(None, |scene| {
2464 let start_x = bounds.min_x() + (bounds.width() / 2.) - (branch_style.width / 2.);
2465 let end_x = bounds.max_x();
2466 let start_y = bounds.min_y();
2467 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
2468
2469 scene.push_quad(gpui::Quad {
2470 bounds: RectF::from_points(
2471 vec2f(start_x, start_y),
2472 vec2f(
2473 start_x + branch_style.width,
2474 if is_last { end_y } else { bounds.max_y() },
2475 ),
2476 ),
2477 background: Some(branch_style.color),
2478 border: gpui::Border::default(),
2479 corner_radii: (0.).into(),
2480 });
2481 scene.push_quad(gpui::Quad {
2482 bounds: RectF::from_points(
2483 vec2f(start_x, end_y),
2484 vec2f(end_x, end_y + branch_style.width),
2485 ),
2486 background: Some(branch_style.color),
2487 border: gpui::Border::default(),
2488 corner_radii: (0.).into(),
2489 });
2490 })
2491 })
2492 .constrained()
2493 .with_width(size.x())
2494}
2495
2496impl View for CollabPanel {
2497 fn ui_name() -> &'static str {
2498 "CollabPanel"
2499 }
2500
2501 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
2502 if !self.has_focus {
2503 self.has_focus = true;
2504 if !self.context_menu.is_focused(cx) {
2505 if let Some(editing_state) = &self.channel_editing_state {
2506 if editing_state.pending_name().is_none() {
2507 cx.focus(&self.channel_name_editor);
2508 } else {
2509 cx.focus(&self.filter_editor);
2510 }
2511 } else {
2512 cx.focus(&self.filter_editor);
2513 }
2514 }
2515 cx.emit(Event::Focus);
2516 }
2517 }
2518
2519 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
2520 self.has_focus = false;
2521 }
2522
2523 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
2524 let theme = &theme::current(cx).collab_panel;
2525
2526 if self.user_store.read(cx).current_user().is_none() {
2527 enum LogInButton {}
2528
2529 return Flex::column()
2530 .with_child(
2531 MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
2532 let button = theme.log_in_button.style_for(state);
2533 Label::new("Sign in to collaborate", button.text.clone())
2534 .aligned()
2535 .left()
2536 .contained()
2537 .with_style(button.container)
2538 })
2539 .on_click(MouseButton::Left, |_, this, cx| {
2540 let client = this.client.clone();
2541 cx.spawn(|_, cx| async move {
2542 client.authenticate_and_connect(true, &cx).await.log_err();
2543 })
2544 .detach();
2545 })
2546 .with_cursor_style(CursorStyle::PointingHand),
2547 )
2548 .contained()
2549 .with_style(theme.container)
2550 .into_any();
2551 }
2552
2553 enum PanelFocus {}
2554 MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
2555 Stack::new()
2556 .with_child(
2557 Flex::column()
2558 .with_child(
2559 Flex::row()
2560 .with_child(
2561 ChildView::new(&self.filter_editor, cx)
2562 .contained()
2563 .with_style(theme.user_query_editor.container)
2564 .flex(1.0, true),
2565 )
2566 .constrained()
2567 .with_width(self.size(cx)),
2568 )
2569 .with_child(
2570 List::new(self.list_state.clone())
2571 .constrained()
2572 .with_width(self.size(cx))
2573 .flex(1., true)
2574 .into_any(),
2575 )
2576 .contained()
2577 .with_style(theme.container)
2578 .constrained()
2579 .with_width(self.size(cx))
2580 .into_any(),
2581 )
2582 .with_children(
2583 (!self.context_menu_on_selected)
2584 .then(|| ChildView::new(&self.context_menu, cx)),
2585 )
2586 .into_any()
2587 })
2588 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
2589 .into_any_named("collab panel")
2590 }
2591}
2592
2593impl Panel for CollabPanel {
2594 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
2595 match settings::get::<CollaborationPanelSettings>(cx).dock {
2596 CollaborationPanelDockPosition::Left => DockPosition::Left,
2597 CollaborationPanelDockPosition::Right => DockPosition::Right,
2598 }
2599 }
2600
2601 fn position_is_valid(&self, position: DockPosition) -> bool {
2602 matches!(position, DockPosition::Left | DockPosition::Right)
2603 }
2604
2605 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2606 settings::update_settings_file::<CollaborationPanelSettings>(
2607 self.fs.clone(),
2608 cx,
2609 move |settings| {
2610 let dock = match position {
2611 DockPosition::Left | DockPosition::Bottom => {
2612 CollaborationPanelDockPosition::Left
2613 }
2614 DockPosition::Right => CollaborationPanelDockPosition::Right,
2615 };
2616 settings.dock = Some(dock);
2617 },
2618 );
2619 }
2620
2621 fn size(&self, cx: &gpui::WindowContext) -> f32 {
2622 self.width
2623 .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
2624 }
2625
2626 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
2627 self.width = size;
2628 self.serialize(cx);
2629 cx.notify();
2630 }
2631
2632 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
2633 settings::get::<CollaborationPanelSettings>(cx)
2634 .button
2635 .then(|| "icons/conversations.svg")
2636 }
2637
2638 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
2639 (
2640 "Collaboration Panel".to_string(),
2641 Some(Box::new(ToggleFocus)),
2642 )
2643 }
2644
2645 fn should_change_position_on_event(event: &Self::Event) -> bool {
2646 matches!(event, Event::DockPositionChanged)
2647 }
2648
2649 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
2650 self.has_focus
2651 }
2652
2653 fn is_focus_event(event: &Self::Event) -> bool {
2654 matches!(event, Event::Focus)
2655 }
2656}
2657
2658impl PartialEq for ListEntry {
2659 fn eq(&self, other: &Self) -> bool {
2660 match self {
2661 ListEntry::Header(section_1, depth_1) => {
2662 if let ListEntry::Header(section_2, depth_2) = other {
2663 return section_1 == section_2 && depth_1 == depth_2;
2664 }
2665 }
2666 ListEntry::CallParticipant { user: user_1, .. } => {
2667 if let ListEntry::CallParticipant { user: user_2, .. } = other {
2668 return user_1.id == user_2.id;
2669 }
2670 }
2671 ListEntry::ParticipantProject {
2672 project_id: project_id_1,
2673 ..
2674 } => {
2675 if let ListEntry::ParticipantProject {
2676 project_id: project_id_2,
2677 ..
2678 } = other
2679 {
2680 return project_id_1 == project_id_2;
2681 }
2682 }
2683 ListEntry::ParticipantScreen {
2684 peer_id: peer_id_1, ..
2685 } => {
2686 if let ListEntry::ParticipantScreen {
2687 peer_id: peer_id_2, ..
2688 } = other
2689 {
2690 return peer_id_1 == peer_id_2;
2691 }
2692 }
2693 ListEntry::Channel {
2694 channel: channel_1,
2695 depth: depth_1,
2696 } => {
2697 if let ListEntry::Channel {
2698 channel: channel_2,
2699 depth: depth_2,
2700 } = other
2701 {
2702 return channel_1.id == channel_2.id && depth_1 == depth_2;
2703 }
2704 }
2705 ListEntry::ChannelNotes { channel_id } => {
2706 if let ListEntry::ChannelNotes {
2707 channel_id: other_id,
2708 } = other
2709 {
2710 return channel_id == other_id;
2711 }
2712 }
2713 ListEntry::ChannelInvite(channel_1) => {
2714 if let ListEntry::ChannelInvite(channel_2) = other {
2715 return channel_1.id == channel_2.id;
2716 }
2717 }
2718 ListEntry::IncomingRequest(user_1) => {
2719 if let ListEntry::IncomingRequest(user_2) = other {
2720 return user_1.id == user_2.id;
2721 }
2722 }
2723 ListEntry::OutgoingRequest(user_1) => {
2724 if let ListEntry::OutgoingRequest(user_2) = other {
2725 return user_1.id == user_2.id;
2726 }
2727 }
2728 ListEntry::Contact {
2729 contact: contact_1, ..
2730 } => {
2731 if let ListEntry::Contact {
2732 contact: contact_2, ..
2733 } = other
2734 {
2735 return contact_1.user.id == contact_2.user.id;
2736 }
2737 }
2738 ListEntry::ChannelEditor { depth } => {
2739 if let ListEntry::ChannelEditor { depth: other_depth } = other {
2740 return depth == other_depth;
2741 }
2742 }
2743 ListEntry::ContactPlaceholder => {
2744 if let ListEntry::ContactPlaceholder = other {
2745 return true;
2746 }
2747 }
2748 }
2749 false
2750 }
2751}
2752
2753fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
2754 Svg::new(svg_path)
2755 .with_color(style.color)
2756 .constrained()
2757 .with_width(style.icon_width)
2758 .aligned()
2759 .constrained()
2760 .with_width(style.button_width)
2761 .with_height(style.button_width)
2762 .contained()
2763 .with_style(style.container)
2764}