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