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