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