1mod channel_modal;
2mod contact_finder;
3
4use crate::{
5 channel_view::{self, ChannelView},
6 chat_panel::ChatPanel,
7 face_pile::FacePile,
8 panel_settings, CollaborationPanelSettings,
9};
10use anyhow::Result;
11use call::ActiveCall;
12use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
13use channel_modal::ChannelModal;
14use client::{
15 proto::{self, PeerId},
16 Client, Contact, User, UserStore,
17};
18use contact_finder::ContactFinder;
19use context_menu::{ContextMenu, ContextMenuItem};
20use db::kvp::KEY_VALUE_STORE;
21use drag_and_drop::{DragAndDrop, Draggable};
22use editor::{Cancel, Editor};
23use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt};
24use futures::StreamExt;
25use fuzzy::{match_strings, StringMatchCandidate};
26use gpui::{
27 actions,
28 elements::{
29 Canvas, ChildView, Component, ContainerStyle, Empty, Flex, Image, Label, List, ListOffset,
30 ListState, MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement,
31 SafeStylable, Stack, Svg,
32 },
33 fonts::TextStyle,
34 geometry::{
35 rect::RectF,
36 vector::{vec2f, Vector2F},
37 },
38 impl_actions,
39 platform::{CursorStyle, MouseButton, PromptLevel},
40 serde_json, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, FontCache,
41 ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
42};
43use menu::{Confirm, SelectNext, SelectPrev};
44use project::{Fs, Project};
45use serde_derive::{Deserialize, Serialize};
46use settings::SettingsStore;
47use std::{borrow::Cow, hash::Hash, mem, sync::Arc};
48use theme::{components::ComponentExt, IconButton, Interactive};
49use util::{iife, ResultExt, TryFutureExt};
50use workspace::{
51 dock::{DockPosition, Panel},
52 item::ItemHandle,
53 FollowNextCollaborator, Workspace,
54};
55
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57struct ToggleCollapse {
58 location: ChannelId,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62struct NewChannel {
63 location: ChannelId,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67struct RenameChannel {
68 channel_id: ChannelId,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72struct ToggleSelectedIx {
73 ix: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77struct RemoveChannel {
78 channel_id: ChannelId,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82struct InviteMembers {
83 channel_id: ChannelId,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87struct ManageMembers {
88 channel_id: ChannelId,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub struct OpenChannelNotes {
93 pub channel_id: ChannelId,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97pub struct JoinChannelCall {
98 pub channel_id: u64,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
102pub struct JoinChannelChat {
103 pub channel_id: u64,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub struct CopyChannelLink {
108 pub channel_id: u64,
109}
110
111#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112struct StartMoveChannelFor {
113 channel_id: ChannelId,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
117struct MoveChannel {
118 to: ChannelId,
119}
120
121actions!(
122 collab_panel,
123 [
124 ToggleFocus,
125 Remove,
126 Secondary,
127 CollapseSelectedChannel,
128 ExpandSelectedChannel,
129 StartMoveChannel,
130 MoveSelected,
131 InsertSpace,
132 ]
133);
134
135impl_actions!(
136 collab_panel,
137 [
138 RemoveChannel,
139 NewChannel,
140 InviteMembers,
141 ManageMembers,
142 RenameChannel,
143 ToggleCollapse,
144 OpenChannelNotes,
145 JoinChannelCall,
146 JoinChannelChat,
147 CopyChannelLink,
148 StartMoveChannelFor,
149 MoveChannel,
150 ToggleSelectedIx
151 ]
152);
153
154#[derive(Debug, Copy, Clone, PartialEq, Eq)]
155struct ChannelMoveClipboard {
156 channel_id: ChannelId,
157}
158
159const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
160
161pub fn init(cx: &mut AppContext) {
162 settings::register::<panel_settings::CollaborationPanelSettings>(cx);
163 contact_finder::init(cx);
164 channel_modal::init(cx);
165 channel_view::init(cx);
166
167 cx.add_action(CollabPanel::cancel);
168 cx.add_action(CollabPanel::select_next);
169 cx.add_action(CollabPanel::select_prev);
170 cx.add_action(CollabPanel::confirm);
171 cx.add_action(CollabPanel::insert_space);
172 cx.add_action(CollabPanel::remove);
173 cx.add_action(CollabPanel::remove_selected_channel);
174 cx.add_action(CollabPanel::show_inline_context_menu);
175 cx.add_action(CollabPanel::new_subchannel);
176 cx.add_action(CollabPanel::invite_members);
177 cx.add_action(CollabPanel::manage_members);
178 cx.add_action(CollabPanel::rename_selected_channel);
179 cx.add_action(CollabPanel::rename_channel);
180 cx.add_action(CollabPanel::toggle_channel_collapsed_action);
181 cx.add_action(CollabPanel::collapse_selected_channel);
182 cx.add_action(CollabPanel::expand_selected_channel);
183 cx.add_action(CollabPanel::open_channel_notes);
184 cx.add_action(CollabPanel::join_channel_chat);
185 cx.add_action(CollabPanel::copy_channel_link);
186
187 cx.add_action(
188 |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext<CollabPanel>| {
189 if panel.selection.take() != Some(action.ix) {
190 panel.selection = Some(action.ix)
191 }
192
193 cx.notify();
194 },
195 );
196
197 cx.add_action(
198 |panel: &mut CollabPanel,
199 action: &StartMoveChannelFor,
200 _: &mut ViewContext<CollabPanel>| {
201 panel.channel_clipboard = Some(ChannelMoveClipboard {
202 channel_id: action.channel_id,
203 });
204 },
205 );
206
207 cx.add_action(
208 |panel: &mut CollabPanel, _: &StartMoveChannel, _: &mut ViewContext<CollabPanel>| {
209 if let Some(channel) = panel.selected_channel() {
210 panel.channel_clipboard = Some(ChannelMoveClipboard {
211 channel_id: channel.id,
212 })
213 }
214 },
215 );
216
217 cx.add_action(
218 |panel: &mut CollabPanel, _: &MoveSelected, cx: &mut ViewContext<CollabPanel>| {
219 let Some(clipboard) = panel.channel_clipboard.take() else {
220 return;
221 };
222 let Some(selected_channel) = panel.selected_channel() else {
223 return;
224 };
225
226 panel
227 .channel_store
228 .update(cx, |channel_store, cx| {
229 channel_store.move_channel(clipboard.channel_id, Some(selected_channel.id), cx)
230 })
231 .detach_and_log_err(cx)
232 },
233 );
234
235 cx.add_action(
236 |panel: &mut CollabPanel, action: &MoveChannel, cx: &mut ViewContext<CollabPanel>| {
237 if let Some(clipboard) = panel.channel_clipboard.take() {
238 panel.channel_store.update(cx, |channel_store, cx| {
239 channel_store
240 .move_channel(clipboard.channel_id, Some(action.to), cx)
241 .detach_and_log_err(cx)
242 })
243 }
244 },
245 );
246}
247
248#[derive(Debug)]
249pub enum ChannelEditingState {
250 Create {
251 location: Option<ChannelId>,
252 pending_name: Option<String>,
253 },
254 Rename {
255 location: ChannelId,
256 pending_name: Option<String>,
257 },
258}
259
260impl ChannelEditingState {
261 fn pending_name(&self) -> Option<&str> {
262 match self {
263 ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
264 ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
265 }
266 }
267}
268
269pub struct CollabPanel {
270 width: Option<f32>,
271 fs: Arc<dyn Fs>,
272 has_focus: bool,
273 channel_clipboard: Option<ChannelMoveClipboard>,
274 pending_serialization: Task<Option<()>>,
275 context_menu: ViewHandle<ContextMenu>,
276 filter_editor: ViewHandle<Editor>,
277 channel_name_editor: ViewHandle<Editor>,
278 channel_editing_state: Option<ChannelEditingState>,
279 entries: Vec<ListEntry>,
280 selection: Option<usize>,
281 user_store: ModelHandle<UserStore>,
282 client: Arc<Client>,
283 channel_store: ModelHandle<ChannelStore>,
284 project: ModelHandle<Project>,
285 match_candidates: Vec<StringMatchCandidate>,
286 list_state: ListState<Self>,
287 subscriptions: Vec<Subscription>,
288 collapsed_sections: Vec<Section>,
289 collapsed_channels: Vec<ChannelId>,
290 drag_target_channel: ChannelDragTarget,
291 workspace: WeakViewHandle<Workspace>,
292 context_menu_on_selected: bool,
293}
294
295#[derive(PartialEq, Eq)]
296enum ChannelDragTarget {
297 None,
298 Root,
299 Channel(ChannelId),
300}
301
302#[derive(Serialize, Deserialize)]
303struct SerializedCollabPanel {
304 width: Option<f32>,
305 collapsed_channels: Option<Vec<ChannelId>>,
306}
307
308#[derive(Debug)]
309pub enum Event {
310 DockPositionChanged,
311 Focus,
312 Dismissed,
313}
314
315#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
316enum Section {
317 ActiveCall,
318 Channels,
319 ChannelInvites,
320 ContactRequests,
321 Contacts,
322 Online,
323 Offline,
324}
325
326#[derive(Clone, Debug)]
327enum ListEntry {
328 Header(Section),
329 CallParticipant {
330 user: Arc<User>,
331 peer_id: Option<PeerId>,
332 is_pending: bool,
333 },
334 ParticipantProject {
335 project_id: u64,
336 worktree_root_names: Vec<String>,
337 host_user_id: u64,
338 is_last: bool,
339 },
340 ParticipantScreen {
341 peer_id: Option<PeerId>,
342 is_last: bool,
343 },
344 IncomingRequest(Arc<User>),
345 OutgoingRequest(Arc<User>),
346 ChannelInvite(Arc<Channel>),
347 Channel {
348 channel: Arc<Channel>,
349 depth: usize,
350 has_children: bool,
351 },
352 ChannelNotes {
353 channel_id: ChannelId,
354 },
355 ChannelChat {
356 channel_id: ChannelId,
357 },
358 ChannelEditor {
359 depth: usize,
360 },
361 Contact {
362 contact: Arc<Contact>,
363 calling: bool,
364 },
365 ContactPlaceholder,
366}
367
368impl Entity for CollabPanel {
369 type Event = Event;
370}
371
372impl CollabPanel {
373 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
374 cx.add_view::<Self, _>(|cx| {
375 let view_id = cx.view_id();
376
377 let filter_editor = cx.add_view(|cx| {
378 let mut editor = Editor::single_line(
379 Some(Arc::new(|theme| {
380 theme.collab_panel.user_query_editor.clone()
381 })),
382 cx,
383 );
384 editor.set_placeholder_text("Filter channels, contacts", cx);
385 editor
386 });
387
388 cx.subscribe(&filter_editor, |this, _, event, cx| {
389 if let editor::Event::BufferEdited = event {
390 let query = this.filter_editor.read(cx).text(cx);
391 if !query.is_empty() {
392 this.selection.take();
393 }
394 this.update_entries(true, cx);
395 if !query.is_empty() {
396 this.selection = this
397 .entries
398 .iter()
399 .position(|entry| !matches!(entry, ListEntry::Header(_)));
400 }
401 } else if let editor::Event::Blurred = event {
402 let query = this.filter_editor.read(cx).text(cx);
403 if query.is_empty() {
404 this.selection.take();
405 this.update_entries(true, cx);
406 }
407 }
408 })
409 .detach();
410
411 let channel_name_editor = cx.add_view(|cx| {
412 Editor::single_line(
413 Some(Arc::new(|theme| {
414 theme.collab_panel.user_query_editor.clone()
415 })),
416 cx,
417 )
418 });
419
420 cx.subscribe(&channel_name_editor, |this, _, event, cx| {
421 if let editor::Event::Blurred = event {
422 if let Some(state) = &this.channel_editing_state {
423 if state.pending_name().is_some() {
424 return;
425 }
426 }
427 this.take_editing_state(cx);
428 this.update_entries(false, cx);
429 cx.notify();
430 }
431 })
432 .detach();
433
434 let list_state =
435 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
436 let theme = theme::current(cx).clone();
437 let is_selected = this.selection == Some(ix);
438 let current_project_id = this.project.read(cx).remote_id();
439
440 match &this.entries[ix] {
441 ListEntry::Header(section) => {
442 let is_collapsed = this.collapsed_sections.contains(section);
443 this.render_header(*section, &theme, is_selected, is_collapsed, cx)
444 }
445 ListEntry::CallParticipant {
446 user,
447 peer_id,
448 is_pending,
449 } => Self::render_call_participant(
450 user,
451 *peer_id,
452 this.user_store.clone(),
453 *is_pending,
454 is_selected,
455 &theme,
456 cx,
457 ),
458 ListEntry::ParticipantProject {
459 project_id,
460 worktree_root_names,
461 host_user_id,
462 is_last,
463 } => Self::render_participant_project(
464 *project_id,
465 worktree_root_names,
466 *host_user_id,
467 Some(*project_id) == current_project_id,
468 *is_last,
469 is_selected,
470 &theme,
471 cx,
472 ),
473 ListEntry::ParticipantScreen { peer_id, is_last } => {
474 Self::render_participant_screen(
475 *peer_id,
476 *is_last,
477 is_selected,
478 &theme.collab_panel,
479 cx,
480 )
481 }
482 ListEntry::Channel {
483 channel,
484 depth,
485 has_children,
486 } => {
487 let channel_row = this.render_channel(
488 &*channel,
489 *depth,
490 &theme,
491 is_selected,
492 *has_children,
493 ix,
494 cx,
495 );
496
497 if is_selected && this.context_menu_on_selected {
498 Stack::new()
499 .with_child(channel_row)
500 .with_child(
501 ChildView::new(&this.context_menu, cx)
502 .aligned()
503 .bottom()
504 .right(),
505 )
506 .into_any()
507 } else {
508 return channel_row;
509 }
510 }
511 ListEntry::ChannelNotes { channel_id } => this.render_channel_notes(
512 *channel_id,
513 &theme.collab_panel,
514 is_selected,
515 ix,
516 cx,
517 ),
518 ListEntry::ChannelChat { channel_id } => this.render_channel_chat(
519 *channel_id,
520 &theme.collab_panel,
521 is_selected,
522 ix,
523 cx,
524 ),
525 ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
526 channel.clone(),
527 this.channel_store.clone(),
528 &theme.collab_panel,
529 is_selected,
530 cx,
531 ),
532 ListEntry::IncomingRequest(user) => Self::render_contact_request(
533 user.clone(),
534 this.user_store.clone(),
535 &theme.collab_panel,
536 true,
537 is_selected,
538 cx,
539 ),
540 ListEntry::OutgoingRequest(user) => Self::render_contact_request(
541 user.clone(),
542 this.user_store.clone(),
543 &theme.collab_panel,
544 false,
545 is_selected,
546 cx,
547 ),
548 ListEntry::Contact { contact, calling } => Self::render_contact(
549 contact,
550 *calling,
551 &this.project,
552 &theme,
553 is_selected,
554 cx,
555 ),
556 ListEntry::ChannelEditor { depth } => {
557 this.render_channel_editor(&theme, *depth, cx)
558 }
559 ListEntry::ContactPlaceholder => {
560 this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
561 }
562 }
563 });
564
565 let mut this = Self {
566 width: None,
567 has_focus: false,
568 channel_clipboard: None,
569 fs: workspace.app_state().fs.clone(),
570 pending_serialization: Task::ready(None),
571 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
572 channel_name_editor,
573 filter_editor,
574 entries: Vec::default(),
575 channel_editing_state: None,
576 selection: None,
577 user_store: workspace.user_store().clone(),
578 channel_store: ChannelStore::global(cx),
579 project: workspace.project().clone(),
580 subscriptions: Vec::default(),
581 match_candidates: Vec::default(),
582 collapsed_sections: vec![Section::Offline],
583 collapsed_channels: Vec::default(),
584 workspace: workspace.weak_handle(),
585 client: workspace.app_state().client.clone(),
586 context_menu_on_selected: true,
587 drag_target_channel: ChannelDragTarget::None,
588 list_state,
589 };
590
591 this.update_entries(false, cx);
592
593 // Update the dock position when the setting changes.
594 let mut old_dock_position = this.position(cx);
595 this.subscriptions
596 .push(
597 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
598 let new_dock_position = this.position(cx);
599 if new_dock_position != old_dock_position {
600 old_dock_position = new_dock_position;
601 cx.emit(Event::DockPositionChanged);
602 }
603 cx.notify();
604 }),
605 );
606
607 let active_call = ActiveCall::global(cx);
608 this.subscriptions
609 .push(cx.observe(&this.user_store, |this, _, cx| {
610 this.update_entries(true, cx)
611 }));
612 this.subscriptions
613 .push(cx.observe(&this.channel_store, |this, _, cx| {
614 this.update_entries(true, cx)
615 }));
616 this.subscriptions
617 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
618 this.subscriptions
619 .push(cx.observe_flag::<ChannelsAlpha, _>(move |_, this, cx| {
620 this.update_entries(true, cx)
621 }));
622 this.subscriptions.push(cx.subscribe(
623 &this.channel_store,
624 |this, _channel_store, e, cx| match e {
625 ChannelEvent::ChannelCreated(channel_id)
626 | ChannelEvent::ChannelRenamed(channel_id) => {
627 if this.take_editing_state(cx) {
628 this.update_entries(false, cx);
629 this.selection = this.entries.iter().position(|entry| {
630 if let ListEntry::Channel { channel, .. } = entry {
631 channel.id == *channel_id
632 } else {
633 false
634 }
635 });
636 }
637 }
638 },
639 ));
640
641 this
642 })
643 }
644
645 pub fn load(
646 workspace: WeakViewHandle<Workspace>,
647 cx: AsyncAppContext,
648 ) -> Task<Result<ViewHandle<Self>>> {
649 cx.spawn(|mut cx| async move {
650 let serialized_panel = if let Some(panel) = cx
651 .background()
652 .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
653 .await
654 .log_err()
655 .flatten()
656 {
657 match serde_json::from_str::<SerializedCollabPanel>(&panel) {
658 Ok(panel) => Some(panel),
659 Err(err) => {
660 log::error!("Failed to deserialize collaboration panel: {}", err);
661 None
662 }
663 }
664 } else {
665 None
666 };
667
668 workspace.update(&mut cx, |workspace, cx| {
669 let panel = CollabPanel::new(workspace, cx);
670 if let Some(serialized_panel) = serialized_panel {
671 panel.update(cx, |panel, cx| {
672 panel.width = serialized_panel.width;
673 panel.collapsed_channels = serialized_panel
674 .collapsed_channels
675 .unwrap_or_else(|| Vec::new());
676 cx.notify();
677 });
678 }
679 panel
680 })
681 })
682 }
683
684 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
685 let width = self.width;
686 let collapsed_channels = self.collapsed_channels.clone();
687 self.pending_serialization = cx.background().spawn(
688 async move {
689 KEY_VALUE_STORE
690 .write_kvp(
691 COLLABORATION_PANEL_KEY.into(),
692 serde_json::to_string(&SerializedCollabPanel {
693 width,
694 collapsed_channels: Some(collapsed_channels),
695 })?,
696 )
697 .await?;
698 anyhow::Ok(())
699 }
700 .log_err(),
701 );
702 }
703
704 fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
705 let channel_store = self.channel_store.read(cx);
706 let user_store = self.user_store.read(cx);
707 let query = self.filter_editor.read(cx).text(cx);
708 let executor = cx.background().clone();
709
710 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
711 let old_entries = mem::take(&mut self.entries);
712 let mut scroll_to_top = false;
713
714 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
715 self.entries.push(ListEntry::Header(Section::ActiveCall));
716 if !old_entries
717 .iter()
718 .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall)))
719 {
720 scroll_to_top = true;
721 }
722
723 if !self.collapsed_sections.contains(&Section::ActiveCall) {
724 let room = room.read(cx);
725
726 if let Some(channel_id) = room.channel_id() {
727 self.entries.push(ListEntry::ChannelNotes { channel_id });
728 self.entries.push(ListEntry::ChannelChat { channel_id })
729 }
730
731 // Populate the active user.
732 if let Some(user) = user_store.current_user() {
733 self.match_candidates.clear();
734 self.match_candidates.push(StringMatchCandidate {
735 id: 0,
736 string: user.github_login.clone(),
737 char_bag: user.github_login.chars().collect(),
738 });
739 let matches = executor.block(match_strings(
740 &self.match_candidates,
741 &query,
742 true,
743 usize::MAX,
744 &Default::default(),
745 executor.clone(),
746 ));
747 if !matches.is_empty() {
748 let user_id = user.id;
749 self.entries.push(ListEntry::CallParticipant {
750 user,
751 peer_id: None,
752 is_pending: false,
753 });
754 let mut projects = room.local_participant().projects.iter().peekable();
755 while let Some(project) = projects.next() {
756 self.entries.push(ListEntry::ParticipantProject {
757 project_id: project.id,
758 worktree_root_names: project.worktree_root_names.clone(),
759 host_user_id: user_id,
760 is_last: projects.peek().is_none() && !room.is_screen_sharing(),
761 });
762 }
763 if room.is_screen_sharing() {
764 self.entries.push(ListEntry::ParticipantScreen {
765 peer_id: None,
766 is_last: true,
767 });
768 }
769 }
770 }
771
772 // Populate remote participants.
773 self.match_candidates.clear();
774 self.match_candidates
775 .extend(room.remote_participants().iter().map(|(_, participant)| {
776 StringMatchCandidate {
777 id: participant.user.id as usize,
778 string: participant.user.github_login.clone(),
779 char_bag: participant.user.github_login.chars().collect(),
780 }
781 }));
782 let matches = executor.block(match_strings(
783 &self.match_candidates,
784 &query,
785 true,
786 usize::MAX,
787 &Default::default(),
788 executor.clone(),
789 ));
790 for mat in matches {
791 let user_id = mat.candidate_id as u64;
792 let participant = &room.remote_participants()[&user_id];
793 self.entries.push(ListEntry::CallParticipant {
794 user: participant.user.clone(),
795 peer_id: Some(participant.peer_id),
796 is_pending: false,
797 });
798 let mut projects = participant.projects.iter().peekable();
799 while let Some(project) = projects.next() {
800 self.entries.push(ListEntry::ParticipantProject {
801 project_id: project.id,
802 worktree_root_names: project.worktree_root_names.clone(),
803 host_user_id: participant.user.id,
804 is_last: projects.peek().is_none()
805 && participant.video_tracks.is_empty(),
806 });
807 }
808 if !participant.video_tracks.is_empty() {
809 self.entries.push(ListEntry::ParticipantScreen {
810 peer_id: Some(participant.peer_id),
811 is_last: true,
812 });
813 }
814 }
815
816 // Populate pending participants.
817 self.match_candidates.clear();
818 self.match_candidates
819 .extend(room.pending_participants().iter().enumerate().map(
820 |(id, participant)| StringMatchCandidate {
821 id,
822 string: participant.github_login.clone(),
823 char_bag: participant.github_login.chars().collect(),
824 },
825 ));
826 let matches = executor.block(match_strings(
827 &self.match_candidates,
828 &query,
829 true,
830 usize::MAX,
831 &Default::default(),
832 executor.clone(),
833 ));
834 self.entries
835 .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
836 user: room.pending_participants()[mat.candidate_id].clone(),
837 peer_id: None,
838 is_pending: true,
839 }));
840 }
841 }
842
843 let mut request_entries = Vec::new();
844
845 if cx.has_flag::<ChannelsAlpha>() {
846 self.entries.push(ListEntry::Header(Section::Channels));
847
848 if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
849 self.match_candidates.clear();
850 self.match_candidates
851 .extend(channel_store.ordered_channels().enumerate().map(
852 |(ix, (_, channel))| StringMatchCandidate {
853 id: ix,
854 string: channel.name.clone(),
855 char_bag: channel.name.chars().collect(),
856 },
857 ));
858 let matches = executor.block(match_strings(
859 &self.match_candidates,
860 &query,
861 true,
862 usize::MAX,
863 &Default::default(),
864 executor.clone(),
865 ));
866 if let Some(state) = &self.channel_editing_state {
867 if matches!(state, ChannelEditingState::Create { location: None, .. }) {
868 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
869 }
870 }
871 let mut collapse_depth = None;
872 for mat in matches {
873 let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
874 let depth = channel.parent_path.len();
875
876 if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
877 collapse_depth = Some(depth);
878 } else if let Some(collapsed_depth) = collapse_depth {
879 if depth > collapsed_depth {
880 continue;
881 }
882 if self.is_channel_collapsed(channel.id) {
883 collapse_depth = Some(depth);
884 } else {
885 collapse_depth = None;
886 }
887 }
888
889 let has_children = channel_store
890 .channel_at_index(mat.candidate_id + 1)
891 .map_or(false, |next_channel| {
892 next_channel.parent_path.ends_with(&[channel.id])
893 });
894
895 match &self.channel_editing_state {
896 Some(ChannelEditingState::Create {
897 location: parent_id,
898 ..
899 }) if *parent_id == Some(channel.id) => {
900 self.entries.push(ListEntry::Channel {
901 channel: channel.clone(),
902 depth,
903 has_children: false,
904 });
905 self.entries
906 .push(ListEntry::ChannelEditor { depth: depth + 1 });
907 }
908 Some(ChannelEditingState::Rename {
909 location: parent_id,
910 ..
911 }) if parent_id == &channel.id => {
912 self.entries.push(ListEntry::ChannelEditor { depth });
913 }
914 _ => {
915 self.entries.push(ListEntry::Channel {
916 channel: channel.clone(),
917 depth,
918 has_children,
919 });
920 }
921 }
922 }
923 }
924
925 let channel_invites = channel_store.channel_invitations();
926 if !channel_invites.is_empty() {
927 self.match_candidates.clear();
928 self.match_candidates
929 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
930 StringMatchCandidate {
931 id: ix,
932 string: channel.name.clone(),
933 char_bag: channel.name.chars().collect(),
934 }
935 }));
936 let matches = executor.block(match_strings(
937 &self.match_candidates,
938 &query,
939 true,
940 usize::MAX,
941 &Default::default(),
942 executor.clone(),
943 ));
944 request_entries.extend(matches.iter().map(|mat| {
945 ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
946 }));
947
948 if !request_entries.is_empty() {
949 self.entries
950 .push(ListEntry::Header(Section::ChannelInvites));
951 if !self.collapsed_sections.contains(&Section::ChannelInvites) {
952 self.entries.append(&mut request_entries);
953 }
954 }
955 }
956 }
957
958 self.entries.push(ListEntry::Header(Section::Contacts));
959
960 request_entries.clear();
961 let incoming = user_store.incoming_contact_requests();
962 if !incoming.is_empty() {
963 self.match_candidates.clear();
964 self.match_candidates
965 .extend(
966 incoming
967 .iter()
968 .enumerate()
969 .map(|(ix, user)| StringMatchCandidate {
970 id: ix,
971 string: user.github_login.clone(),
972 char_bag: user.github_login.chars().collect(),
973 }),
974 );
975 let matches = executor.block(match_strings(
976 &self.match_candidates,
977 &query,
978 true,
979 usize::MAX,
980 &Default::default(),
981 executor.clone(),
982 ));
983 request_entries.extend(
984 matches
985 .iter()
986 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
987 );
988 }
989
990 let outgoing = user_store.outgoing_contact_requests();
991 if !outgoing.is_empty() {
992 self.match_candidates.clear();
993 self.match_candidates
994 .extend(
995 outgoing
996 .iter()
997 .enumerate()
998 .map(|(ix, user)| StringMatchCandidate {
999 id: ix,
1000 string: user.github_login.clone(),
1001 char_bag: user.github_login.chars().collect(),
1002 }),
1003 );
1004 let matches = executor.block(match_strings(
1005 &self.match_candidates,
1006 &query,
1007 true,
1008 usize::MAX,
1009 &Default::default(),
1010 executor.clone(),
1011 ));
1012 request_entries.extend(
1013 matches
1014 .iter()
1015 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
1016 );
1017 }
1018
1019 if !request_entries.is_empty() {
1020 self.entries
1021 .push(ListEntry::Header(Section::ContactRequests));
1022 if !self.collapsed_sections.contains(&Section::ContactRequests) {
1023 self.entries.append(&mut request_entries);
1024 }
1025 }
1026
1027 let contacts = user_store.contacts();
1028 if !contacts.is_empty() {
1029 self.match_candidates.clear();
1030 self.match_candidates
1031 .extend(
1032 contacts
1033 .iter()
1034 .enumerate()
1035 .map(|(ix, contact)| StringMatchCandidate {
1036 id: ix,
1037 string: contact.user.github_login.clone(),
1038 char_bag: contact.user.github_login.chars().collect(),
1039 }),
1040 );
1041
1042 let matches = executor.block(match_strings(
1043 &self.match_candidates,
1044 &query,
1045 true,
1046 usize::MAX,
1047 &Default::default(),
1048 executor.clone(),
1049 ));
1050
1051 let (online_contacts, offline_contacts) = matches
1052 .iter()
1053 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
1054
1055 for (matches, section) in [
1056 (online_contacts, Section::Online),
1057 (offline_contacts, Section::Offline),
1058 ] {
1059 if !matches.is_empty() {
1060 self.entries.push(ListEntry::Header(section));
1061 if !self.collapsed_sections.contains(§ion) {
1062 let active_call = &ActiveCall::global(cx).read(cx);
1063 for mat in matches {
1064 let contact = &contacts[mat.candidate_id];
1065 self.entries.push(ListEntry::Contact {
1066 contact: contact.clone(),
1067 calling: active_call.pending_invites().contains(&contact.user.id),
1068 });
1069 }
1070 }
1071 }
1072 }
1073 }
1074
1075 if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
1076 self.entries.push(ListEntry::ContactPlaceholder);
1077 }
1078
1079 if select_same_item {
1080 if let Some(prev_selected_entry) = prev_selected_entry {
1081 self.selection.take();
1082 for (ix, entry) in self.entries.iter().enumerate() {
1083 if *entry == prev_selected_entry {
1084 self.selection = Some(ix);
1085 break;
1086 }
1087 }
1088 }
1089 } else {
1090 self.selection = self.selection.and_then(|prev_selection| {
1091 if self.entries.is_empty() {
1092 None
1093 } else {
1094 Some(prev_selection.min(self.entries.len() - 1))
1095 }
1096 });
1097 }
1098
1099 let old_scroll_top = self.list_state.logical_scroll_top();
1100
1101 self.list_state.reset(self.entries.len());
1102
1103 if scroll_to_top {
1104 self.list_state.scroll_to(ListOffset::default());
1105 } else {
1106 // Attempt to maintain the same scroll position.
1107 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
1108 let new_scroll_top = self
1109 .entries
1110 .iter()
1111 .position(|entry| entry == old_top_entry)
1112 .map(|item_ix| ListOffset {
1113 item_ix,
1114 offset_in_item: old_scroll_top.offset_in_item,
1115 })
1116 .or_else(|| {
1117 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
1118 let item_ix = self
1119 .entries
1120 .iter()
1121 .position(|entry| entry == entry_after_old_top)?;
1122 Some(ListOffset {
1123 item_ix,
1124 offset_in_item: 0.,
1125 })
1126 })
1127 .or_else(|| {
1128 let entry_before_old_top =
1129 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
1130 let item_ix = self
1131 .entries
1132 .iter()
1133 .position(|entry| entry == entry_before_old_top)?;
1134 Some(ListOffset {
1135 item_ix,
1136 offset_in_item: 0.,
1137 })
1138 });
1139
1140 self.list_state
1141 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
1142 }
1143 }
1144
1145 cx.notify();
1146 }
1147
1148 fn render_call_participant(
1149 user: &User,
1150 peer_id: Option<PeerId>,
1151 user_store: ModelHandle<UserStore>,
1152 is_pending: bool,
1153 is_selected: bool,
1154 theme: &theme::Theme,
1155 cx: &mut ViewContext<Self>,
1156 ) -> AnyElement<Self> {
1157 enum CallParticipant {}
1158 enum CallParticipantTooltip {}
1159 enum LeaveCallButton {}
1160 enum LeaveCallTooltip {}
1161
1162 let collab_theme = &theme.collab_panel;
1163
1164 let is_current_user =
1165 user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
1166
1167 let content = MouseEventHandler::new::<CallParticipant, _>(
1168 user.id as usize,
1169 cx,
1170 |mouse_state, cx| {
1171 let style = if is_current_user {
1172 *collab_theme
1173 .contact_row
1174 .in_state(is_selected)
1175 .style_for(&mut Default::default())
1176 } else {
1177 *collab_theme
1178 .contact_row
1179 .in_state(is_selected)
1180 .style_for(mouse_state)
1181 };
1182
1183 Flex::row()
1184 .with_children(user.avatar.clone().map(|avatar| {
1185 Image::from_data(avatar)
1186 .with_style(collab_theme.contact_avatar)
1187 .aligned()
1188 .left()
1189 }))
1190 .with_child(
1191 Label::new(
1192 user.github_login.clone(),
1193 collab_theme.contact_username.text.clone(),
1194 )
1195 .contained()
1196 .with_style(collab_theme.contact_username.container)
1197 .aligned()
1198 .left()
1199 .flex(1., true),
1200 )
1201 .with_children(if is_pending {
1202 Some(
1203 Label::new("Calling", collab_theme.calling_indicator.text.clone())
1204 .contained()
1205 .with_style(collab_theme.calling_indicator.container)
1206 .aligned()
1207 .into_any(),
1208 )
1209 } else if is_current_user {
1210 Some(
1211 MouseEventHandler::new::<LeaveCallButton, _>(0, cx, |state, _| {
1212 render_icon_button(
1213 theme
1214 .collab_panel
1215 .leave_call_button
1216 .style_for(is_selected, state),
1217 "icons/exit.svg",
1218 )
1219 })
1220 .with_cursor_style(CursorStyle::PointingHand)
1221 .on_click(MouseButton::Left, |_, _, cx| {
1222 Self::leave_call(cx);
1223 })
1224 .with_tooltip::<LeaveCallTooltip>(
1225 0,
1226 "Leave call",
1227 None,
1228 theme.tooltip.clone(),
1229 cx,
1230 )
1231 .into_any(),
1232 )
1233 } else {
1234 None
1235 })
1236 .constrained()
1237 .with_height(collab_theme.row_height)
1238 .contained()
1239 .with_style(style)
1240 },
1241 );
1242
1243 if is_current_user || is_pending || peer_id.is_none() {
1244 return content.into_any();
1245 }
1246
1247 let tooltip = format!("Follow {}", user.github_login);
1248
1249 content
1250 .on_click(MouseButton::Left, move |_, this, cx| {
1251 if let Some(workspace) = this.workspace.upgrade(cx) {
1252 workspace
1253 .update(cx, |workspace, cx| workspace.follow(peer_id.unwrap(), cx))
1254 .map(|task| task.detach_and_log_err(cx));
1255 }
1256 })
1257 .with_cursor_style(CursorStyle::PointingHand)
1258 .with_tooltip::<CallParticipantTooltip>(
1259 user.id as usize,
1260 tooltip,
1261 Some(Box::new(FollowNextCollaborator)),
1262 theme.tooltip.clone(),
1263 cx,
1264 )
1265 .into_any()
1266 }
1267
1268 fn render_participant_project(
1269 project_id: u64,
1270 worktree_root_names: &[String],
1271 host_user_id: u64,
1272 is_current: bool,
1273 is_last: bool,
1274 is_selected: bool,
1275 theme: &theme::Theme,
1276 cx: &mut ViewContext<Self>,
1277 ) -> AnyElement<Self> {
1278 enum JoinProject {}
1279 enum JoinProjectTooltip {}
1280
1281 let collab_theme = &theme.collab_panel;
1282 let host_avatar_width = collab_theme
1283 .contact_avatar
1284 .width
1285 .or(collab_theme.contact_avatar.height)
1286 .unwrap_or(0.);
1287 let tree_branch = collab_theme.tree_branch;
1288 let project_name = if worktree_root_names.is_empty() {
1289 "untitled".to_string()
1290 } else {
1291 worktree_root_names.join(", ")
1292 };
1293
1294 let content =
1295 MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, cx| {
1296 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1297 let row = if is_current {
1298 collab_theme
1299 .project_row
1300 .in_state(true)
1301 .style_for(&mut Default::default())
1302 } else {
1303 collab_theme
1304 .project_row
1305 .in_state(is_selected)
1306 .style_for(mouse_state)
1307 };
1308
1309 Flex::row()
1310 .with_child(render_tree_branch(
1311 tree_branch,
1312 &row.name.text,
1313 is_last,
1314 vec2f(host_avatar_width, collab_theme.row_height),
1315 cx.font_cache(),
1316 ))
1317 .with_child(
1318 Svg::new("icons/file_icons/folder.svg")
1319 .with_color(collab_theme.channel_hash.color)
1320 .constrained()
1321 .with_width(collab_theme.channel_hash.width)
1322 .aligned()
1323 .left(),
1324 )
1325 .with_child(
1326 Label::new(project_name.clone(), row.name.text.clone())
1327 .aligned()
1328 .left()
1329 .contained()
1330 .with_style(row.name.container)
1331 .flex(1., false),
1332 )
1333 .constrained()
1334 .with_height(collab_theme.row_height)
1335 .contained()
1336 .with_style(row.container)
1337 });
1338
1339 if is_current {
1340 return content.into_any();
1341 }
1342
1343 content
1344 .with_cursor_style(CursorStyle::PointingHand)
1345 .on_click(MouseButton::Left, move |_, this, cx| {
1346 if let Some(workspace) = this.workspace.upgrade(cx) {
1347 let app_state = workspace.read(cx).app_state().clone();
1348 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
1349 .detach_and_log_err(cx);
1350 }
1351 })
1352 .with_tooltip::<JoinProjectTooltip>(
1353 project_id as usize,
1354 format!("Open {}", project_name),
1355 None,
1356 theme.tooltip.clone(),
1357 cx,
1358 )
1359 .into_any()
1360 }
1361
1362 fn render_participant_screen(
1363 peer_id: Option<PeerId>,
1364 is_last: bool,
1365 is_selected: bool,
1366 theme: &theme::CollabPanel,
1367 cx: &mut ViewContext<Self>,
1368 ) -> AnyElement<Self> {
1369 enum OpenSharedScreen {}
1370
1371 let host_avatar_width = theme
1372 .contact_avatar
1373 .width
1374 .or(theme.contact_avatar.height)
1375 .unwrap_or(0.);
1376 let tree_branch = theme.tree_branch;
1377
1378 let handler = MouseEventHandler::new::<OpenSharedScreen, _>(
1379 peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize,
1380 cx,
1381 |mouse_state, cx| {
1382 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
1383 let row = theme
1384 .project_row
1385 .in_state(is_selected)
1386 .style_for(mouse_state);
1387
1388 Flex::row()
1389 .with_child(render_tree_branch(
1390 tree_branch,
1391 &row.name.text,
1392 is_last,
1393 vec2f(host_avatar_width, theme.row_height),
1394 cx.font_cache(),
1395 ))
1396 .with_child(
1397 Svg::new("icons/desktop.svg")
1398 .with_color(theme.channel_hash.color)
1399 .constrained()
1400 .with_width(theme.channel_hash.width)
1401 .aligned()
1402 .left(),
1403 )
1404 .with_child(
1405 Label::new("Screen", row.name.text.clone())
1406 .aligned()
1407 .left()
1408 .contained()
1409 .with_style(row.name.container)
1410 .flex(1., false),
1411 )
1412 .constrained()
1413 .with_height(theme.row_height)
1414 .contained()
1415 .with_style(row.container)
1416 },
1417 );
1418 if peer_id.is_none() {
1419 return handler.into_any();
1420 }
1421 handler
1422 .with_cursor_style(CursorStyle::PointingHand)
1423 .on_click(MouseButton::Left, move |_, this, cx| {
1424 if let Some(workspace) = this.workspace.upgrade(cx) {
1425 workspace.update(cx, |workspace, cx| {
1426 workspace.open_shared_screen(peer_id.unwrap(), cx)
1427 });
1428 }
1429 })
1430 .into_any()
1431 }
1432
1433 fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
1434 if let Some(_) = self.channel_editing_state.take() {
1435 self.channel_name_editor.update(cx, |editor, cx| {
1436 editor.set_text("", cx);
1437 });
1438 true
1439 } else {
1440 false
1441 }
1442 }
1443
1444 fn render_header(
1445 &self,
1446 section: Section,
1447 theme: &theme::Theme,
1448 is_selected: bool,
1449 is_collapsed: bool,
1450 cx: &mut ViewContext<Self>,
1451 ) -> AnyElement<Self> {
1452 enum Header {}
1453 enum LeaveCallContactList {}
1454 enum AddChannel {}
1455
1456 let tooltip_style = &theme.tooltip;
1457 let mut channel_link = None;
1458 let mut channel_tooltip_text = None;
1459 let mut channel_icon = None;
1460 let mut is_dragged_over = false;
1461
1462 let text = match section {
1463 Section::ActiveCall => {
1464 let channel_name = iife!({
1465 let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
1466
1467 let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
1468
1469 channel_link = Some(channel.link());
1470 (channel_icon, channel_tooltip_text) = match channel.visibility {
1471 proto::ChannelVisibility::Public => {
1472 (Some("icons/public.svg"), Some("Copy public channel link."))
1473 }
1474 proto::ChannelVisibility::Members => {
1475 (Some("icons/hash.svg"), Some("Copy private channel link."))
1476 }
1477 };
1478
1479 Some(channel.name.as_str())
1480 });
1481
1482 if let Some(name) = channel_name {
1483 Cow::Owned(format!("{}", name))
1484 } else {
1485 Cow::Borrowed("Current Call")
1486 }
1487 }
1488 Section::ContactRequests => Cow::Borrowed("Requests"),
1489 Section::Contacts => Cow::Borrowed("Contacts"),
1490 Section::Channels => Cow::Borrowed("Channels"),
1491 Section::ChannelInvites => Cow::Borrowed("Invites"),
1492 Section::Online => Cow::Borrowed("Online"),
1493 Section::Offline => Cow::Borrowed("Offline"),
1494 };
1495
1496 enum AddContact {}
1497 let button = match section {
1498 Section::ActiveCall => channel_link.map(|channel_link| {
1499 let channel_link_copy = channel_link.clone();
1500 MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
1501 render_icon_button(
1502 theme
1503 .collab_panel
1504 .leave_call_button
1505 .style_for(is_selected, state),
1506 "icons/link.svg",
1507 )
1508 })
1509 .with_cursor_style(CursorStyle::PointingHand)
1510 .on_click(MouseButton::Left, move |_, _, cx| {
1511 let item = ClipboardItem::new(channel_link_copy.clone());
1512 cx.write_to_clipboard(item)
1513 })
1514 .with_tooltip::<AddContact>(
1515 0,
1516 channel_tooltip_text.unwrap(),
1517 None,
1518 tooltip_style.clone(),
1519 cx,
1520 )
1521 }),
1522 Section::Contacts => Some(
1523 MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
1524 render_icon_button(
1525 theme
1526 .collab_panel
1527 .add_contact_button
1528 .style_for(is_selected, state),
1529 "icons/plus.svg",
1530 )
1531 })
1532 .with_cursor_style(CursorStyle::PointingHand)
1533 .on_click(MouseButton::Left, |_, this, cx| {
1534 this.toggle_contact_finder(cx);
1535 })
1536 .with_tooltip::<LeaveCallContactList>(
1537 0,
1538 "Search for new contact",
1539 None,
1540 tooltip_style.clone(),
1541 cx,
1542 ),
1543 ),
1544 Section::Channels => {
1545 if cx
1546 .global::<DragAndDrop<Workspace>>()
1547 .currently_dragged::<Channel>(cx.window())
1548 .is_some()
1549 && self.drag_target_channel == ChannelDragTarget::Root
1550 {
1551 is_dragged_over = true;
1552 }
1553
1554 Some(
1555 MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
1556 render_icon_button(
1557 theme
1558 .collab_panel
1559 .add_contact_button
1560 .style_for(is_selected, state),
1561 "icons/plus.svg",
1562 )
1563 })
1564 .with_cursor_style(CursorStyle::PointingHand)
1565 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
1566 .with_tooltip::<AddChannel>(
1567 0,
1568 "Create a channel",
1569 None,
1570 tooltip_style.clone(),
1571 cx,
1572 ),
1573 )
1574 }
1575 _ => None,
1576 };
1577
1578 let can_collapse = match section {
1579 Section::ActiveCall | Section::Channels | Section::Contacts => false,
1580 Section::ChannelInvites
1581 | Section::ContactRequests
1582 | Section::Online
1583 | Section::Offline => true,
1584 };
1585 let icon_size = (&theme.collab_panel).section_icon_size;
1586 let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
1587 let header_style = if can_collapse {
1588 theme
1589 .collab_panel
1590 .subheader_row
1591 .in_state(is_selected)
1592 .style_for(state)
1593 } else {
1594 &theme.collab_panel.header_row
1595 };
1596
1597 Flex::row()
1598 .with_children(if can_collapse {
1599 Some(
1600 Svg::new(if is_collapsed {
1601 "icons/chevron_right.svg"
1602 } else {
1603 "icons/chevron_down.svg"
1604 })
1605 .with_color(header_style.text.color)
1606 .constrained()
1607 .with_max_width(icon_size)
1608 .with_max_height(icon_size)
1609 .aligned()
1610 .constrained()
1611 .with_width(icon_size)
1612 .contained()
1613 .with_margin_right(
1614 theme.collab_panel.contact_username.container.margin.left,
1615 ),
1616 )
1617 } else if let Some(channel_icon) = channel_icon {
1618 Some(
1619 Svg::new(channel_icon)
1620 .with_color(header_style.text.color)
1621 .constrained()
1622 .with_max_width(icon_size)
1623 .with_max_height(icon_size)
1624 .aligned()
1625 .constrained()
1626 .with_width(icon_size)
1627 .contained()
1628 .with_margin_right(
1629 theme.collab_panel.contact_username.container.margin.left,
1630 ),
1631 )
1632 } else {
1633 None
1634 })
1635 .with_child(
1636 Label::new(text, header_style.text.clone())
1637 .aligned()
1638 .left()
1639 .flex(1., true),
1640 )
1641 .with_children(button.map(|button| button.aligned().right()))
1642 .constrained()
1643 .with_height(theme.collab_panel.row_height)
1644 .contained()
1645 .with_style(if is_dragged_over {
1646 theme.collab_panel.dragged_over_header
1647 } else {
1648 header_style.container
1649 })
1650 });
1651
1652 result = result
1653 .on_move(move |_, this, cx| {
1654 if cx
1655 .global::<DragAndDrop<Workspace>>()
1656 .currently_dragged::<Channel>(cx.window())
1657 .is_some()
1658 {
1659 this.drag_target_channel = ChannelDragTarget::Root;
1660 cx.notify()
1661 }
1662 })
1663 .on_up(MouseButton::Left, move |_, this, cx| {
1664 if let Some((_, dragged_channel)) = cx
1665 .global::<DragAndDrop<Workspace>>()
1666 .currently_dragged::<Channel>(cx.window())
1667 {
1668 this.channel_store
1669 .update(cx, |channel_store, cx| {
1670 channel_store.move_channel(dragged_channel.id, None, cx)
1671 })
1672 .detach_and_log_err(cx)
1673 }
1674 });
1675
1676 if can_collapse {
1677 result = result
1678 .with_cursor_style(CursorStyle::PointingHand)
1679 .on_click(MouseButton::Left, move |_, this, cx| {
1680 if can_collapse {
1681 this.toggle_section_expanded(section, cx);
1682 }
1683 })
1684 }
1685
1686 result.into_any()
1687 }
1688
1689 fn render_contact(
1690 contact: &Contact,
1691 calling: bool,
1692 project: &ModelHandle<Project>,
1693 theme: &theme::Theme,
1694 is_selected: bool,
1695 cx: &mut ViewContext<Self>,
1696 ) -> AnyElement<Self> {
1697 enum ContactTooltip {}
1698
1699 let collab_theme = &theme.collab_panel;
1700 let online = contact.online;
1701 let busy = contact.busy || calling;
1702 let user_id = contact.user.id;
1703 let github_login = contact.user.github_login.clone();
1704 let initial_project = project.clone();
1705
1706 let event_handler =
1707 MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
1708 Flex::row()
1709 .with_children(contact.user.avatar.clone().map(|avatar| {
1710 let status_badge = if contact.online {
1711 Some(
1712 Empty::new()
1713 .collapsed()
1714 .contained()
1715 .with_style(if busy {
1716 collab_theme.contact_status_busy
1717 } else {
1718 collab_theme.contact_status_free
1719 })
1720 .aligned(),
1721 )
1722 } else {
1723 None
1724 };
1725 Stack::new()
1726 .with_child(
1727 Image::from_data(avatar)
1728 .with_style(collab_theme.contact_avatar)
1729 .aligned()
1730 .left(),
1731 )
1732 .with_children(status_badge)
1733 }))
1734 .with_child(
1735 Label::new(
1736 contact.user.github_login.clone(),
1737 collab_theme.contact_username.text.clone(),
1738 )
1739 .contained()
1740 .with_style(collab_theme.contact_username.container)
1741 .aligned()
1742 .left()
1743 .flex(1., true),
1744 )
1745 .with_children(if state.hovered() {
1746 Some(
1747 MouseEventHandler::new::<Cancel, _>(
1748 contact.user.id as usize,
1749 cx,
1750 |mouse_state, _| {
1751 let button_style =
1752 collab_theme.contact_button.style_for(mouse_state);
1753 render_icon_button(button_style, "icons/x.svg")
1754 .aligned()
1755 .flex_float()
1756 },
1757 )
1758 .with_padding(Padding::uniform(2.))
1759 .with_cursor_style(CursorStyle::PointingHand)
1760 .on_click(MouseButton::Left, move |_, this, cx| {
1761 this.remove_contact(user_id, &github_login, cx);
1762 })
1763 .flex_float(),
1764 )
1765 } else {
1766 None
1767 })
1768 .with_children(if calling {
1769 Some(
1770 Label::new("Calling", collab_theme.calling_indicator.text.clone())
1771 .contained()
1772 .with_style(collab_theme.calling_indicator.container)
1773 .aligned(),
1774 )
1775 } else {
1776 None
1777 })
1778 .constrained()
1779 .with_height(collab_theme.row_height)
1780 .contained()
1781 .with_style(
1782 *collab_theme
1783 .contact_row
1784 .in_state(is_selected)
1785 .style_for(state),
1786 )
1787 });
1788
1789 if online && !busy {
1790 let room = ActiveCall::global(cx).read(cx).room();
1791 let label = if room.is_some() {
1792 format!("Invite {} to join call", contact.user.github_login)
1793 } else {
1794 format!("Call {}", contact.user.github_login)
1795 };
1796
1797 event_handler
1798 .on_click(MouseButton::Left, move |_, this, cx| {
1799 this.call(user_id, Some(initial_project.clone()), cx);
1800 })
1801 .with_cursor_style(CursorStyle::PointingHand)
1802 .with_tooltip::<ContactTooltip>(
1803 contact.user.id as usize,
1804 label,
1805 None,
1806 theme.tooltip.clone(),
1807 cx,
1808 )
1809 .into_any()
1810 } else {
1811 event_handler
1812 .with_tooltip::<ContactTooltip>(
1813 contact.user.id as usize,
1814 format!(
1815 "{} is {}",
1816 contact.user.github_login,
1817 if busy { "on a call" } else { "offline" }
1818 ),
1819 None,
1820 theme.tooltip.clone(),
1821 cx,
1822 )
1823 .into_any()
1824 }
1825 }
1826
1827 fn render_contact_placeholder(
1828 &self,
1829 theme: &theme::CollabPanel,
1830 is_selected: bool,
1831 cx: &mut ViewContext<Self>,
1832 ) -> AnyElement<Self> {
1833 enum AddContacts {}
1834 MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
1835 let style = theme.list_empty_state.style_for(is_selected, state);
1836 Flex::row()
1837 .with_child(
1838 Svg::new("icons/plus.svg")
1839 .with_color(theme.list_empty_icon.color)
1840 .constrained()
1841 .with_width(theme.list_empty_icon.width)
1842 .aligned()
1843 .left(),
1844 )
1845 .with_child(
1846 Label::new("Add a contact", style.text.clone())
1847 .contained()
1848 .with_style(theme.list_empty_label_container),
1849 )
1850 .align_children_center()
1851 .contained()
1852 .with_style(style.container)
1853 .into_any()
1854 })
1855 .on_click(MouseButton::Left, |_, this, cx| {
1856 this.toggle_contact_finder(cx);
1857 })
1858 .into_any()
1859 }
1860
1861 fn render_channel_editor(
1862 &self,
1863 theme: &theme::Theme,
1864 depth: usize,
1865 cx: &AppContext,
1866 ) -> AnyElement<Self> {
1867 Flex::row()
1868 .with_child(
1869 Empty::new()
1870 .constrained()
1871 .with_width(theme.collab_panel.disclosure.button_space()),
1872 )
1873 .with_child(
1874 Svg::new("icons/hash.svg")
1875 .with_color(theme.collab_panel.channel_hash.color)
1876 .constrained()
1877 .with_width(theme.collab_panel.channel_hash.width)
1878 .aligned()
1879 .left(),
1880 )
1881 .with_child(
1882 if let Some(pending_name) = self
1883 .channel_editing_state
1884 .as_ref()
1885 .and_then(|state| state.pending_name())
1886 {
1887 Label::new(
1888 pending_name.to_string(),
1889 theme.collab_panel.contact_username.text.clone(),
1890 )
1891 .contained()
1892 .with_style(theme.collab_panel.contact_username.container)
1893 .aligned()
1894 .left()
1895 .flex(1., true)
1896 .into_any()
1897 } else {
1898 ChildView::new(&self.channel_name_editor, cx)
1899 .aligned()
1900 .left()
1901 .contained()
1902 .with_style(theme.collab_panel.channel_editor)
1903 .flex(1.0, true)
1904 .into_any()
1905 },
1906 )
1907 .align_children_center()
1908 .constrained()
1909 .with_height(theme.collab_panel.row_height)
1910 .contained()
1911 .with_style(ContainerStyle {
1912 background_color: Some(theme.editor.background),
1913 ..*theme.collab_panel.contact_row.default_style()
1914 })
1915 .with_padding_left(
1916 theme.collab_panel.contact_row.default_style().padding.left
1917 + theme.collab_panel.channel_indent * depth as f32,
1918 )
1919 .into_any()
1920 }
1921
1922 fn render_channel(
1923 &self,
1924 channel: &Channel,
1925 depth: usize,
1926 theme: &theme::Theme,
1927 is_selected: bool,
1928 has_children: bool,
1929 ix: usize,
1930 cx: &mut ViewContext<Self>,
1931 ) -> AnyElement<Self> {
1932 let channel_id = channel.id;
1933 let collab_theme = &theme.collab_panel;
1934 let is_public = self
1935 .channel_store
1936 .read(cx)
1937 .channel_for_id(channel_id)
1938 .map(|channel| channel.visibility)
1939 == Some(proto::ChannelVisibility::Public);
1940 let other_selected = self.selected_channel().map(|channel| channel.id) == Some(channel.id);
1941 let disclosed =
1942 has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
1943
1944 let is_active = iife!({
1945 let call_channel = ActiveCall::global(cx)
1946 .read(cx)
1947 .room()?
1948 .read(cx)
1949 .channel_id()?;
1950 Some(call_channel == channel_id)
1951 })
1952 .unwrap_or(false);
1953
1954 const FACEPILE_LIMIT: usize = 3;
1955
1956 enum ChannelCall {}
1957 enum ChannelNote {}
1958 enum NotesTooltip {}
1959 enum ChatTooltip {}
1960 enum ChannelTooltip {}
1961
1962 let mut is_dragged_over = false;
1963 if cx
1964 .global::<DragAndDrop<Workspace>>()
1965 .currently_dragged::<Channel>(cx.window())
1966 .is_some()
1967 && self.drag_target_channel == ChannelDragTarget::Channel(channel_id)
1968 {
1969 is_dragged_over = true;
1970 }
1971
1972 let has_messages_notification = channel.unseen_message_id.is_some();
1973
1974 MouseEventHandler::new::<Channel, _>(ix, cx, |state, cx| {
1975 let row_hovered = state.hovered();
1976
1977 let mut select_state = |interactive: &Interactive<ContainerStyle>| {
1978 if state.clicked() == Some(MouseButton::Left) && interactive.clicked.is_some() {
1979 interactive.clicked.as_ref().unwrap().clone()
1980 } else if state.hovered() || other_selected {
1981 interactive
1982 .hovered
1983 .as_ref()
1984 .unwrap_or(&interactive.default)
1985 .clone()
1986 } else {
1987 interactive.default.clone()
1988 }
1989 };
1990
1991 Flex::<Self>::row()
1992 .with_child(
1993 Svg::new(if is_public {
1994 "icons/public.svg"
1995 } else {
1996 "icons/hash.svg"
1997 })
1998 .with_color(collab_theme.channel_hash.color)
1999 .constrained()
2000 .with_width(collab_theme.channel_hash.width)
2001 .aligned()
2002 .left(),
2003 )
2004 .with_child({
2005 let style = collab_theme.channel_name.inactive_state();
2006 Flex::row()
2007 .with_child(
2008 Label::new(channel.name.clone(), style.text.clone())
2009 .contained()
2010 .with_style(style.container)
2011 .aligned()
2012 .left()
2013 .with_tooltip::<ChannelTooltip>(
2014 ix,
2015 "Join channel",
2016 None,
2017 theme.tooltip.clone(),
2018 cx,
2019 ),
2020 )
2021 .with_children({
2022 let participants =
2023 self.channel_store.read(cx).channel_participants(channel_id);
2024
2025 if !participants.is_empty() {
2026 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
2027
2028 let result = FacePile::new(collab_theme.face_overlap)
2029 .with_children(
2030 participants
2031 .iter()
2032 .filter_map(|user| {
2033 Some(
2034 Image::from_data(user.avatar.clone()?)
2035 .with_style(collab_theme.channel_avatar),
2036 )
2037 })
2038 .take(FACEPILE_LIMIT),
2039 )
2040 .with_children((extra_count > 0).then(|| {
2041 Label::new(
2042 format!("+{}", extra_count),
2043 collab_theme.extra_participant_label.text.clone(),
2044 )
2045 .contained()
2046 .with_style(collab_theme.extra_participant_label.container)
2047 }));
2048
2049 Some(result)
2050 } else {
2051 None
2052 }
2053 })
2054 .with_spacing(8.)
2055 .align_children_center()
2056 .flex(1., true)
2057 })
2058 .with_child(
2059 MouseEventHandler::new::<ChannelNote, _>(ix, cx, move |mouse_state, _| {
2060 let container_style = collab_theme
2061 .disclosure
2062 .button
2063 .style_for(mouse_state)
2064 .container;
2065
2066 if channel.unseen_message_id.is_some() {
2067 Svg::new("icons/conversations.svg")
2068 .with_color(collab_theme.channel_note_active_color)
2069 .constrained()
2070 .with_width(collab_theme.channel_hash.width)
2071 .contained()
2072 .with_style(container_style)
2073 .with_uniform_padding(4.)
2074 .into_any()
2075 } else if row_hovered {
2076 Svg::new("icons/conversations.svg")
2077 .with_color(collab_theme.channel_hash.color)
2078 .constrained()
2079 .with_width(collab_theme.channel_hash.width)
2080 .contained()
2081 .with_style(container_style)
2082 .with_uniform_padding(4.)
2083 .into_any()
2084 } else {
2085 Empty::new().into_any()
2086 }
2087 })
2088 .on_click(MouseButton::Left, move |_, this, cx| {
2089 this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
2090 })
2091 .with_tooltip::<ChatTooltip>(
2092 ix,
2093 "Open channel chat",
2094 None,
2095 theme.tooltip.clone(),
2096 cx,
2097 )
2098 .contained()
2099 .with_margin_right(4.),
2100 )
2101 .with_child(
2102 MouseEventHandler::new::<ChannelCall, _>(ix, cx, move |mouse_state, cx| {
2103 let container_style = collab_theme
2104 .disclosure
2105 .button
2106 .style_for(mouse_state)
2107 .container;
2108 if row_hovered || channel.unseen_note_version.is_some() {
2109 Svg::new("icons/file.svg")
2110 .with_color(if channel.unseen_note_version.is_some() {
2111 collab_theme.channel_note_active_color
2112 } else {
2113 collab_theme.channel_hash.color
2114 })
2115 .constrained()
2116 .with_width(collab_theme.channel_hash.width)
2117 .contained()
2118 .with_style(container_style)
2119 .with_uniform_padding(4.)
2120 .with_margin_right(collab_theme.channel_hash.container.margin.left)
2121 .with_tooltip::<NotesTooltip>(
2122 ix as usize,
2123 "Open channel notes",
2124 None,
2125 theme.tooltip.clone(),
2126 cx,
2127 )
2128 .into_any()
2129 } else if has_messages_notification {
2130 Empty::new()
2131 .constrained()
2132 .with_width(collab_theme.channel_hash.width)
2133 .contained()
2134 .with_uniform_padding(4.)
2135 .with_margin_right(collab_theme.channel_hash.container.margin.left)
2136 .into_any()
2137 } else {
2138 Empty::new().into_any()
2139 }
2140 })
2141 .on_click(MouseButton::Left, move |_, this, cx| {
2142 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
2143 }),
2144 )
2145 .align_children_center()
2146 .styleable_component()
2147 .disclosable(
2148 disclosed,
2149 Box::new(ToggleCollapse {
2150 location: channel.id.clone(),
2151 }),
2152 )
2153 .with_id(ix)
2154 .with_style(collab_theme.disclosure.clone())
2155 .element()
2156 .constrained()
2157 .with_height(collab_theme.row_height)
2158 .contained()
2159 .with_style(select_state(
2160 collab_theme
2161 .channel_row
2162 .in_state(is_selected || is_active || is_dragged_over),
2163 ))
2164 .with_padding_left(
2165 collab_theme.channel_row.default_style().padding.left
2166 + collab_theme.channel_indent * depth as f32,
2167 )
2168 })
2169 .on_click(MouseButton::Left, move |_, this, cx| {
2170 if this.drag_target_channel == ChannelDragTarget::None {
2171 if is_active {
2172 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
2173 } else {
2174 this.join_channel(channel_id, cx)
2175 }
2176 }
2177 })
2178 .on_click(MouseButton::Right, {
2179 let channel = channel.clone();
2180 move |e, this, cx| {
2181 this.deploy_channel_context_menu(Some(e.position), &channel, ix, cx);
2182 }
2183 })
2184 .on_up(MouseButton::Left, move |_, this, cx| {
2185 if let Some((_, dragged_channel)) = cx
2186 .global::<DragAndDrop<Workspace>>()
2187 .currently_dragged::<Channel>(cx.window())
2188 {
2189 this.channel_store
2190 .update(cx, |channel_store, cx| {
2191 channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
2192 })
2193 .detach_and_log_err(cx)
2194 }
2195 })
2196 .on_move({
2197 let channel = channel.clone();
2198 move |_, this, cx| {
2199 if let Some((_, dragged_channel)) = cx
2200 .global::<DragAndDrop<Workspace>>()
2201 .currently_dragged::<Channel>(cx.window())
2202 {
2203 if channel.id != dragged_channel.id {
2204 this.drag_target_channel = ChannelDragTarget::Channel(channel.id);
2205 }
2206 cx.notify()
2207 }
2208 }
2209 })
2210 .as_draggable::<_, Channel>(
2211 channel.clone(),
2212 move |_, channel, cx: &mut ViewContext<Workspace>| {
2213 let theme = &theme::current(cx).collab_panel;
2214
2215 Flex::<Workspace>::row()
2216 .with_child(
2217 Svg::new("icons/hash.svg")
2218 .with_color(theme.channel_hash.color)
2219 .constrained()
2220 .with_width(theme.channel_hash.width)
2221 .aligned()
2222 .left(),
2223 )
2224 .with_child(
2225 Label::new(channel.name.clone(), theme.channel_name.text.clone())
2226 .contained()
2227 .with_style(theme.channel_name.container)
2228 .aligned()
2229 .left(),
2230 )
2231 .align_children_center()
2232 .contained()
2233 .with_background_color(
2234 theme
2235 .container
2236 .background_color
2237 .unwrap_or(gpui::color::Color::transparent_black()),
2238 )
2239 .contained()
2240 .with_padding_left(
2241 theme.channel_row.default_style().padding.left
2242 + theme.channel_indent * depth as f32,
2243 )
2244 .into_any()
2245 },
2246 )
2247 .with_cursor_style(CursorStyle::PointingHand)
2248 .into_any()
2249 }
2250
2251 fn render_channel_notes(
2252 &self,
2253 channel_id: ChannelId,
2254 theme: &theme::CollabPanel,
2255 is_selected: bool,
2256 ix: usize,
2257 cx: &mut ViewContext<Self>,
2258 ) -> AnyElement<Self> {
2259 enum ChannelNotes {}
2260 let host_avatar_width = theme
2261 .contact_avatar
2262 .width
2263 .or(theme.contact_avatar.height)
2264 .unwrap_or(0.);
2265
2266 MouseEventHandler::new::<ChannelNotes, _>(ix as usize, cx, |state, cx| {
2267 let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
2268 let row = theme.project_row.in_state(is_selected).style_for(state);
2269
2270 Flex::<Self>::row()
2271 .with_child(render_tree_branch(
2272 tree_branch,
2273 &row.name.text,
2274 false,
2275 vec2f(host_avatar_width, theme.row_height),
2276 cx.font_cache(),
2277 ))
2278 .with_child(
2279 Svg::new("icons/file.svg")
2280 .with_color(theme.channel_hash.color)
2281 .constrained()
2282 .with_width(theme.channel_hash.width)
2283 .aligned()
2284 .left(),
2285 )
2286 .with_child(
2287 Label::new("notes", theme.channel_name.text.clone())
2288 .contained()
2289 .with_style(theme.channel_name.container)
2290 .aligned()
2291 .left()
2292 .flex(1., true),
2293 )
2294 .constrained()
2295 .with_height(theme.row_height)
2296 .contained()
2297 .with_style(*theme.channel_row.style_for(is_selected, state))
2298 .with_padding_left(theme.channel_row.default_style().padding.left)
2299 })
2300 .on_click(MouseButton::Left, move |_, this, cx| {
2301 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
2302 })
2303 .with_cursor_style(CursorStyle::PointingHand)
2304 .into_any()
2305 }
2306
2307 fn render_channel_chat(
2308 &self,
2309 channel_id: ChannelId,
2310 theme: &theme::CollabPanel,
2311 is_selected: bool,
2312 ix: usize,
2313 cx: &mut ViewContext<Self>,
2314 ) -> AnyElement<Self> {
2315 enum ChannelChat {}
2316 let host_avatar_width = theme
2317 .contact_avatar
2318 .width
2319 .or(theme.contact_avatar.height)
2320 .unwrap_or(0.);
2321
2322 MouseEventHandler::new::<ChannelChat, _>(ix as usize, cx, |state, cx| {
2323 let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
2324 let row = theme.project_row.in_state(is_selected).style_for(state);
2325
2326 Flex::<Self>::row()
2327 .with_child(render_tree_branch(
2328 tree_branch,
2329 &row.name.text,
2330 true,
2331 vec2f(host_avatar_width, theme.row_height),
2332 cx.font_cache(),
2333 ))
2334 .with_child(
2335 Svg::new("icons/conversations.svg")
2336 .with_color(theme.channel_hash.color)
2337 .constrained()
2338 .with_width(theme.channel_hash.width)
2339 .aligned()
2340 .left(),
2341 )
2342 .with_child(
2343 Label::new("chat", theme.channel_name.text.clone())
2344 .contained()
2345 .with_style(theme.channel_name.container)
2346 .aligned()
2347 .left()
2348 .flex(1., true),
2349 )
2350 .constrained()
2351 .with_height(theme.row_height)
2352 .contained()
2353 .with_style(*theme.channel_row.style_for(is_selected, state))
2354 .with_padding_left(theme.channel_row.default_style().padding.left)
2355 })
2356 .on_click(MouseButton::Left, move |_, this, cx| {
2357 this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
2358 })
2359 .with_cursor_style(CursorStyle::PointingHand)
2360 .into_any()
2361 }
2362
2363 fn render_channel_invite(
2364 channel: Arc<Channel>,
2365 channel_store: ModelHandle<ChannelStore>,
2366 theme: &theme::CollabPanel,
2367 is_selected: bool,
2368 cx: &mut ViewContext<Self>,
2369 ) -> AnyElement<Self> {
2370 enum Decline {}
2371 enum Accept {}
2372
2373 let channel_id = channel.id;
2374 let is_invite_pending = channel_store
2375 .read(cx)
2376 .has_pending_channel_invite_response(&channel);
2377 let button_spacing = theme.contact_button_spacing;
2378
2379 Flex::row()
2380 .with_child(
2381 Svg::new("icons/hash.svg")
2382 .with_color(theme.channel_hash.color)
2383 .constrained()
2384 .with_width(theme.channel_hash.width)
2385 .aligned()
2386 .left(),
2387 )
2388 .with_child(
2389 Label::new(channel.name.clone(), theme.contact_username.text.clone())
2390 .contained()
2391 .with_style(theme.contact_username.container)
2392 .aligned()
2393 .left()
2394 .flex(1., true),
2395 )
2396 .with_child(
2397 MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
2398 let button_style = if is_invite_pending {
2399 &theme.disabled_button
2400 } else {
2401 theme.contact_button.style_for(mouse_state)
2402 };
2403 render_icon_button(button_style, "icons/x.svg").aligned()
2404 })
2405 .with_cursor_style(CursorStyle::PointingHand)
2406 .on_click(MouseButton::Left, move |_, this, cx| {
2407 this.respond_to_channel_invite(channel_id, false, cx);
2408 })
2409 .contained()
2410 .with_margin_right(button_spacing),
2411 )
2412 .with_child(
2413 MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
2414 let button_style = if is_invite_pending {
2415 &theme.disabled_button
2416 } else {
2417 theme.contact_button.style_for(mouse_state)
2418 };
2419 render_icon_button(button_style, "icons/check.svg")
2420 .aligned()
2421 .flex_float()
2422 })
2423 .with_cursor_style(CursorStyle::PointingHand)
2424 .on_click(MouseButton::Left, move |_, this, cx| {
2425 this.respond_to_channel_invite(channel_id, true, cx);
2426 }),
2427 )
2428 .constrained()
2429 .with_height(theme.row_height)
2430 .contained()
2431 .with_style(
2432 *theme
2433 .contact_row
2434 .in_state(is_selected)
2435 .style_for(&mut Default::default()),
2436 )
2437 .with_padding_left(
2438 theme.contact_row.default_style().padding.left + theme.channel_indent,
2439 )
2440 .into_any()
2441 }
2442
2443 fn render_contact_request(
2444 user: Arc<User>,
2445 user_store: ModelHandle<UserStore>,
2446 theme: &theme::CollabPanel,
2447 is_incoming: bool,
2448 is_selected: bool,
2449 cx: &mut ViewContext<Self>,
2450 ) -> AnyElement<Self> {
2451 enum Decline {}
2452 enum Accept {}
2453 enum Cancel {}
2454
2455 let mut row = Flex::row()
2456 .with_children(user.avatar.clone().map(|avatar| {
2457 Image::from_data(avatar)
2458 .with_style(theme.contact_avatar)
2459 .aligned()
2460 .left()
2461 }))
2462 .with_child(
2463 Label::new(
2464 user.github_login.clone(),
2465 theme.contact_username.text.clone(),
2466 )
2467 .contained()
2468 .with_style(theme.contact_username.container)
2469 .aligned()
2470 .left()
2471 .flex(1., true),
2472 );
2473
2474 let user_id = user.id;
2475 let github_login = user.github_login.clone();
2476 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
2477 let button_spacing = theme.contact_button_spacing;
2478
2479 if is_incoming {
2480 row.add_child(
2481 MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
2482 let button_style = if is_contact_request_pending {
2483 &theme.disabled_button
2484 } else {
2485 theme.contact_button.style_for(mouse_state)
2486 };
2487 render_icon_button(button_style, "icons/x.svg").aligned()
2488 })
2489 .with_cursor_style(CursorStyle::PointingHand)
2490 .on_click(MouseButton::Left, move |_, this, cx| {
2491 this.respond_to_contact_request(user_id, false, cx);
2492 })
2493 .contained()
2494 .with_margin_right(button_spacing),
2495 );
2496
2497 row.add_child(
2498 MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
2499 let button_style = if is_contact_request_pending {
2500 &theme.disabled_button
2501 } else {
2502 theme.contact_button.style_for(mouse_state)
2503 };
2504 render_icon_button(button_style, "icons/check.svg")
2505 .aligned()
2506 .flex_float()
2507 })
2508 .with_cursor_style(CursorStyle::PointingHand)
2509 .on_click(MouseButton::Left, move |_, this, cx| {
2510 this.respond_to_contact_request(user_id, true, cx);
2511 }),
2512 );
2513 } else {
2514 row.add_child(
2515 MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
2516 let button_style = if is_contact_request_pending {
2517 &theme.disabled_button
2518 } else {
2519 theme.contact_button.style_for(mouse_state)
2520 };
2521 render_icon_button(button_style, "icons/x.svg")
2522 .aligned()
2523 .flex_float()
2524 })
2525 .with_padding(Padding::uniform(2.))
2526 .with_cursor_style(CursorStyle::PointingHand)
2527 .on_click(MouseButton::Left, move |_, this, cx| {
2528 this.remove_contact(user_id, &github_login, cx);
2529 })
2530 .flex_float(),
2531 );
2532 }
2533
2534 row.constrained()
2535 .with_height(theme.row_height)
2536 .contained()
2537 .with_style(
2538 *theme
2539 .contact_row
2540 .in_state(is_selected)
2541 .style_for(&mut Default::default()),
2542 )
2543 .into_any()
2544 }
2545
2546 fn has_subchannels(&self, ix: usize) -> bool {
2547 self.entries.get(ix).map_or(false, |entry| {
2548 if let ListEntry::Channel { has_children, .. } = entry {
2549 *has_children
2550 } else {
2551 false
2552 }
2553 })
2554 }
2555
2556 fn deploy_channel_context_menu(
2557 &mut self,
2558 position: Option<Vector2F>,
2559 channel: &Channel,
2560 ix: usize,
2561 cx: &mut ViewContext<Self>,
2562 ) {
2563 self.context_menu_on_selected = position.is_none();
2564
2565 let clipboard_channel_name = self.channel_clipboard.as_ref().and_then(|clipboard| {
2566 self.channel_store
2567 .read(cx)
2568 .channel_for_id(clipboard.channel_id)
2569 .map(|channel| channel.name.clone())
2570 });
2571
2572 self.context_menu.update(cx, |context_menu, cx| {
2573 context_menu.set_position_mode(if self.context_menu_on_selected {
2574 OverlayPositionMode::Local
2575 } else {
2576 OverlayPositionMode::Window
2577 });
2578
2579 let mut items = Vec::new();
2580
2581 let select_action_name = if self.selection == Some(ix) {
2582 "Unselect"
2583 } else {
2584 "Select"
2585 };
2586
2587 items.push(ContextMenuItem::action(
2588 select_action_name,
2589 ToggleSelectedIx { ix },
2590 ));
2591
2592 if self.has_subchannels(ix) {
2593 let expand_action_name = if self.is_channel_collapsed(channel.id) {
2594 "Expand Subchannels"
2595 } else {
2596 "Collapse Subchannels"
2597 };
2598 items.push(ContextMenuItem::action(
2599 expand_action_name,
2600 ToggleCollapse {
2601 location: channel.id,
2602 },
2603 ));
2604 }
2605
2606 items.push(ContextMenuItem::action(
2607 "Open Notes",
2608 OpenChannelNotes {
2609 channel_id: channel.id,
2610 },
2611 ));
2612
2613 items.push(ContextMenuItem::action(
2614 "Open Chat",
2615 JoinChannelChat {
2616 channel_id: channel.id,
2617 },
2618 ));
2619
2620 items.push(ContextMenuItem::action(
2621 "Copy Channel Link",
2622 CopyChannelLink {
2623 channel_id: channel.id,
2624 },
2625 ));
2626
2627 if self.channel_store.read(cx).is_channel_admin(channel.id) {
2628 items.extend([
2629 ContextMenuItem::Separator,
2630 ContextMenuItem::action(
2631 "New Subchannel",
2632 NewChannel {
2633 location: channel.id,
2634 },
2635 ),
2636 ContextMenuItem::action(
2637 "Rename",
2638 RenameChannel {
2639 channel_id: channel.id,
2640 },
2641 ),
2642 ContextMenuItem::action(
2643 "Move this channel",
2644 StartMoveChannelFor {
2645 channel_id: channel.id,
2646 },
2647 ),
2648 ]);
2649
2650 if let Some(channel_name) = clipboard_channel_name {
2651 items.push(ContextMenuItem::Separator);
2652 items.push(ContextMenuItem::action(
2653 format!("Move '#{}' here", channel_name),
2654 MoveChannel { to: channel.id },
2655 ));
2656 }
2657
2658 items.extend([
2659 ContextMenuItem::Separator,
2660 ContextMenuItem::action(
2661 "Invite Members",
2662 InviteMembers {
2663 channel_id: channel.id,
2664 },
2665 ),
2666 ContextMenuItem::action(
2667 "Manage Members",
2668 ManageMembers {
2669 channel_id: channel.id,
2670 },
2671 ),
2672 ContextMenuItem::Separator,
2673 ContextMenuItem::action(
2674 "Delete",
2675 RemoveChannel {
2676 channel_id: channel.id,
2677 },
2678 ),
2679 ]);
2680 }
2681
2682 context_menu.show(
2683 position.unwrap_or_default(),
2684 if self.context_menu_on_selected {
2685 gpui::elements::AnchorCorner::TopRight
2686 } else {
2687 gpui::elements::AnchorCorner::BottomLeft
2688 },
2689 items,
2690 cx,
2691 );
2692 });
2693
2694 cx.notify();
2695 }
2696
2697 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
2698 if self.take_editing_state(cx) {
2699 cx.focus(&self.filter_editor);
2700 } else {
2701 self.filter_editor.update(cx, |editor, cx| {
2702 if editor.buffer().read(cx).len(cx) > 0 {
2703 editor.set_text("", cx);
2704 }
2705 });
2706 }
2707
2708 self.update_entries(false, cx);
2709 }
2710
2711 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
2712 let ix = self.selection.map_or(0, |ix| ix + 1);
2713 if ix < self.entries.len() {
2714 self.selection = Some(ix);
2715 }
2716
2717 self.list_state.reset(self.entries.len());
2718 if let Some(ix) = self.selection {
2719 self.list_state.scroll_to(ListOffset {
2720 item_ix: ix,
2721 offset_in_item: 0.,
2722 });
2723 }
2724 cx.notify();
2725 }
2726
2727 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
2728 let ix = self.selection.take().unwrap_or(0);
2729 if ix > 0 {
2730 self.selection = Some(ix - 1);
2731 }
2732
2733 self.list_state.reset(self.entries.len());
2734 if let Some(ix) = self.selection {
2735 self.list_state.scroll_to(ListOffset {
2736 item_ix: ix,
2737 offset_in_item: 0.,
2738 });
2739 }
2740 cx.notify();
2741 }
2742
2743 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
2744 if self.confirm_channel_edit(cx) {
2745 return;
2746 }
2747
2748 if let Some(selection) = self.selection {
2749 if let Some(entry) = self.entries.get(selection) {
2750 match entry {
2751 ListEntry::Header(section) => match section {
2752 Section::ActiveCall => Self::leave_call(cx),
2753 Section::Channels => self.new_root_channel(cx),
2754 Section::Contacts => self.toggle_contact_finder(cx),
2755 Section::ContactRequests
2756 | Section::Online
2757 | Section::Offline
2758 | Section::ChannelInvites => {
2759 self.toggle_section_expanded(*section, cx);
2760 }
2761 },
2762 ListEntry::Contact { contact, calling } => {
2763 if contact.online && !contact.busy && !calling {
2764 self.call(contact.user.id, Some(self.project.clone()), cx);
2765 }
2766 }
2767 ListEntry::ParticipantProject {
2768 project_id,
2769 host_user_id,
2770 ..
2771 } => {
2772 if let Some(workspace) = self.workspace.upgrade(cx) {
2773 let app_state = workspace.read(cx).app_state().clone();
2774 workspace::join_remote_project(
2775 *project_id,
2776 *host_user_id,
2777 app_state,
2778 cx,
2779 )
2780 .detach_and_log_err(cx);
2781 }
2782 }
2783 ListEntry::ParticipantScreen { peer_id, .. } => {
2784 let Some(peer_id) = peer_id else {
2785 return;
2786 };
2787 if let Some(workspace) = self.workspace.upgrade(cx) {
2788 workspace.update(cx, |workspace, cx| {
2789 workspace.open_shared_screen(*peer_id, cx)
2790 });
2791 }
2792 }
2793 ListEntry::Channel { channel, .. } => {
2794 let is_active = iife!({
2795 let call_channel = ActiveCall::global(cx)
2796 .read(cx)
2797 .room()?
2798 .read(cx)
2799 .channel_id()?;
2800
2801 Some(call_channel == channel.id)
2802 })
2803 .unwrap_or(false);
2804 if is_active {
2805 self.open_channel_notes(
2806 &OpenChannelNotes {
2807 channel_id: channel.id,
2808 },
2809 cx,
2810 )
2811 } else {
2812 self.join_channel(channel.id, cx)
2813 }
2814 }
2815 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
2816 _ => {}
2817 }
2818 }
2819 }
2820 }
2821
2822 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
2823 if self.channel_editing_state.is_some() {
2824 self.channel_name_editor.update(cx, |editor, cx| {
2825 editor.insert(" ", cx);
2826 });
2827 }
2828 }
2829
2830 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
2831 if let Some(editing_state) = &mut self.channel_editing_state {
2832 match editing_state {
2833 ChannelEditingState::Create {
2834 location,
2835 pending_name,
2836 ..
2837 } => {
2838 if pending_name.is_some() {
2839 return false;
2840 }
2841 let channel_name = self.channel_name_editor.read(cx).text(cx);
2842
2843 *pending_name = Some(channel_name.clone());
2844
2845 self.channel_store
2846 .update(cx, |channel_store, cx| {
2847 channel_store.create_channel(&channel_name, *location, cx)
2848 })
2849 .detach();
2850 cx.notify();
2851 }
2852 ChannelEditingState::Rename {
2853 location,
2854 pending_name,
2855 } => {
2856 if pending_name.is_some() {
2857 return false;
2858 }
2859 let channel_name = self.channel_name_editor.read(cx).text(cx);
2860 *pending_name = Some(channel_name.clone());
2861
2862 self.channel_store
2863 .update(cx, |channel_store, cx| {
2864 channel_store.rename(*location, &channel_name, cx)
2865 })
2866 .detach();
2867 cx.notify();
2868 }
2869 }
2870 cx.focus_self();
2871 true
2872 } else {
2873 false
2874 }
2875 }
2876
2877 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
2878 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
2879 self.collapsed_sections.remove(ix);
2880 } else {
2881 self.collapsed_sections.push(section);
2882 }
2883 self.update_entries(false, cx);
2884 }
2885
2886 fn collapse_selected_channel(
2887 &mut self,
2888 _: &CollapseSelectedChannel,
2889 cx: &mut ViewContext<Self>,
2890 ) {
2891 let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
2892 return;
2893 };
2894
2895 if self.is_channel_collapsed(channel_id) {
2896 return;
2897 }
2898
2899 self.toggle_channel_collapsed(channel_id, cx);
2900 }
2901
2902 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
2903 let Some(id) = self.selected_channel().map(|channel| channel.id) else {
2904 return;
2905 };
2906
2907 if !self.is_channel_collapsed(id) {
2908 return;
2909 }
2910
2911 self.toggle_channel_collapsed(id, cx)
2912 }
2913
2914 fn toggle_channel_collapsed_action(
2915 &mut self,
2916 action: &ToggleCollapse,
2917 cx: &mut ViewContext<Self>,
2918 ) {
2919 self.toggle_channel_collapsed(action.location, cx);
2920 }
2921
2922 fn toggle_channel_collapsed<'a>(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
2923 match self.collapsed_channels.binary_search(&channel_id) {
2924 Ok(ix) => {
2925 self.collapsed_channels.remove(ix);
2926 }
2927 Err(ix) => {
2928 self.collapsed_channels.insert(ix, channel_id);
2929 }
2930 };
2931 self.serialize(cx);
2932 self.update_entries(true, cx);
2933 cx.notify();
2934 cx.focus_self();
2935 }
2936
2937 fn is_channel_collapsed(&self, channel_id: ChannelId) -> bool {
2938 self.collapsed_channels.binary_search(&channel_id).is_ok()
2939 }
2940
2941 fn leave_call(cx: &mut ViewContext<Self>) {
2942 ActiveCall::global(cx)
2943 .update(cx, |call, cx| call.hang_up(cx))
2944 .detach_and_log_err(cx);
2945 }
2946
2947 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
2948 if let Some(workspace) = self.workspace.upgrade(cx) {
2949 workspace.update(cx, |workspace, cx| {
2950 workspace.toggle_modal(cx, |_, cx| {
2951 cx.add_view(|cx| {
2952 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
2953 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
2954 finder
2955 })
2956 });
2957 });
2958 }
2959 }
2960
2961 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
2962 self.channel_editing_state = Some(ChannelEditingState::Create {
2963 location: None,
2964 pending_name: None,
2965 });
2966 self.update_entries(false, cx);
2967 self.select_channel_editor();
2968 cx.focus(self.channel_name_editor.as_any());
2969 cx.notify();
2970 }
2971
2972 fn select_channel_editor(&mut self) {
2973 self.selection = self.entries.iter().position(|entry| match entry {
2974 ListEntry::ChannelEditor { .. } => true,
2975 _ => false,
2976 });
2977 }
2978
2979 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
2980 self.collapsed_channels
2981 .retain(|channel| *channel != action.location);
2982 self.channel_editing_state = Some(ChannelEditingState::Create {
2983 location: Some(action.location.to_owned()),
2984 pending_name: None,
2985 });
2986 self.update_entries(false, cx);
2987 self.select_channel_editor();
2988 cx.focus(self.channel_name_editor.as_any());
2989 cx.notify();
2990 }
2991
2992 fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
2993 self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
2994 }
2995
2996 fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
2997 self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
2998 }
2999
3000 fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
3001 if let Some(channel) = self.selected_channel() {
3002 self.remove_channel(channel.id, cx)
3003 }
3004 }
3005
3006 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
3007 if let Some(channel) = self.selected_channel() {
3008 self.rename_channel(
3009 &RenameChannel {
3010 channel_id: channel.id,
3011 },
3012 cx,
3013 );
3014 }
3015 }
3016
3017 fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
3018 let channel_store = self.channel_store.read(cx);
3019 if !channel_store.is_channel_admin(action.channel_id) {
3020 return;
3021 }
3022 if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
3023 self.channel_editing_state = Some(ChannelEditingState::Rename {
3024 location: action.channel_id.to_owned(),
3025 pending_name: None,
3026 });
3027 self.channel_name_editor.update(cx, |editor, cx| {
3028 editor.set_text(channel.name.clone(), cx);
3029 editor.select_all(&Default::default(), cx);
3030 });
3031 cx.focus(self.channel_name_editor.as_any());
3032 self.update_entries(false, cx);
3033 self.select_channel_editor();
3034 }
3035 }
3036
3037 fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
3038 if let Some(workspace) = self.workspace.upgrade(cx) {
3039 ChannelView::open(action.channel_id, workspace, cx).detach();
3040 }
3041 }
3042
3043 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
3044 let Some(channel) = self.selected_channel() else {
3045 return;
3046 };
3047
3048 self.deploy_channel_context_menu(None, &channel.clone(), self.selection.unwrap(), cx);
3049 }
3050
3051 fn selected_channel(&self) -> Option<&Arc<Channel>> {
3052 self.selection
3053 .and_then(|ix| self.entries.get(ix))
3054 .and_then(|entry| match entry {
3055 ListEntry::Channel { channel, .. } => Some(channel),
3056 _ => None,
3057 })
3058 }
3059
3060 fn show_channel_modal(
3061 &mut self,
3062 channel_id: ChannelId,
3063 mode: channel_modal::Mode,
3064 cx: &mut ViewContext<Self>,
3065 ) {
3066 let workspace = self.workspace.clone();
3067 let user_store = self.user_store.clone();
3068 let channel_store = self.channel_store.clone();
3069 let members = self.channel_store.update(cx, |channel_store, cx| {
3070 channel_store.get_channel_member_details(channel_id, cx)
3071 });
3072
3073 cx.spawn(|_, mut cx| async move {
3074 let members = members.await?;
3075 workspace.update(&mut cx, |workspace, cx| {
3076 workspace.toggle_modal(cx, |_, cx| {
3077 cx.add_view(|cx| {
3078 ChannelModal::new(
3079 user_store.clone(),
3080 channel_store.clone(),
3081 channel_id,
3082 mode,
3083 members,
3084 cx,
3085 )
3086 })
3087 });
3088 })
3089 })
3090 .detach();
3091 }
3092
3093 fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
3094 self.remove_channel(action.channel_id, cx)
3095 }
3096
3097 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
3098 let channel_store = self.channel_store.clone();
3099 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
3100 let prompt_message = format!(
3101 "Are you sure you want to remove the channel \"{}\"?",
3102 channel.name
3103 );
3104 let mut answer =
3105 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
3106 let window = cx.window();
3107 cx.spawn(|this, mut cx| async move {
3108 if answer.next().await == Some(0) {
3109 if let Err(e) = channel_store
3110 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
3111 .await
3112 {
3113 window.prompt(
3114 PromptLevel::Info,
3115 &format!("Failed to remove channel: {}", e),
3116 &["Ok"],
3117 &mut cx,
3118 );
3119 }
3120 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
3121 }
3122 })
3123 .detach();
3124 }
3125 }
3126
3127 // Should move to the filter editor if clicking on it
3128 // Should move selection to the channel editor if activating it
3129
3130 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
3131 let user_store = self.user_store.clone();
3132 let prompt_message = format!(
3133 "Are you sure you want to remove \"{}\" from your contacts?",
3134 github_login
3135 );
3136 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
3137 let window = cx.window();
3138 cx.spawn(|_, mut cx| async move {
3139 if answer.next().await == Some(0) {
3140 if let Err(e) = user_store
3141 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
3142 .await
3143 {
3144 window.prompt(
3145 PromptLevel::Info,
3146 &format!("Failed to remove contact: {}", e),
3147 &["Ok"],
3148 &mut cx,
3149 );
3150 }
3151 }
3152 })
3153 .detach();
3154 }
3155
3156 fn respond_to_contact_request(
3157 &mut self,
3158 user_id: u64,
3159 accept: bool,
3160 cx: &mut ViewContext<Self>,
3161 ) {
3162 self.user_store
3163 .update(cx, |store, cx| {
3164 store.respond_to_contact_request(user_id, accept, cx)
3165 })
3166 .detach();
3167 }
3168
3169 fn respond_to_channel_invite(
3170 &mut self,
3171 channel_id: u64,
3172 accept: bool,
3173 cx: &mut ViewContext<Self>,
3174 ) {
3175 self.channel_store
3176 .update(cx, |store, cx| {
3177 store.respond_to_channel_invite(channel_id, accept, cx)
3178 })
3179 .detach();
3180 }
3181
3182 fn call(
3183 &mut self,
3184 recipient_user_id: u64,
3185 initial_project: Option<ModelHandle<Project>>,
3186 cx: &mut ViewContext<Self>,
3187 ) {
3188 ActiveCall::global(cx)
3189 .update(cx, |call, cx| {
3190 call.invite(recipient_user_id, initial_project, cx)
3191 })
3192 .detach_and_log_err(cx);
3193 }
3194
3195 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
3196 let Some(workspace) = self.workspace.upgrade(cx) else {
3197 return;
3198 };
3199 let Some(handle) = cx.window().downcast::<Workspace>() else {
3200 return;
3201 };
3202 workspace::join_channel(
3203 channel_id,
3204 workspace.read(cx).app_state().clone(),
3205 Some(handle),
3206 cx,
3207 )
3208 .detach_and_log_err(cx)
3209 }
3210
3211 fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
3212 let channel_id = action.channel_id;
3213 if let Some(workspace) = self.workspace.upgrade(cx) {
3214 cx.app_context().defer(move |cx| {
3215 workspace.update(cx, |workspace, cx| {
3216 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
3217 panel.update(cx, |panel, cx| {
3218 panel
3219 .select_channel(channel_id, None, cx)
3220 .detach_and_log_err(cx);
3221 });
3222 }
3223 });
3224 });
3225 }
3226 }
3227
3228 fn copy_channel_link(&mut self, action: &CopyChannelLink, cx: &mut ViewContext<Self>) {
3229 let channel_store = self.channel_store.read(cx);
3230 let Some(channel) = channel_store.channel_for_id(action.channel_id) else {
3231 return;
3232 };
3233 let item = ClipboardItem::new(channel.link());
3234 cx.write_to_clipboard(item)
3235 }
3236}
3237
3238fn render_tree_branch(
3239 branch_style: theme::TreeBranch,
3240 row_style: &TextStyle,
3241 is_last: bool,
3242 size: Vector2F,
3243 font_cache: &FontCache,
3244) -> gpui::elements::ConstrainedBox<CollabPanel> {
3245 let line_height = row_style.line_height(font_cache);
3246 let cap_height = row_style.cap_height(font_cache);
3247 let baseline_offset = row_style.baseline_offset(font_cache) + (size.y() - line_height) / 2.;
3248
3249 Canvas::new(move |bounds, _, _, cx| {
3250 cx.paint_layer(None, |cx| {
3251 let start_x = bounds.min_x() + (bounds.width() / 2.) - (branch_style.width / 2.);
3252 let end_x = bounds.max_x();
3253 let start_y = bounds.min_y();
3254 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
3255
3256 cx.scene().push_quad(gpui::Quad {
3257 bounds: RectF::from_points(
3258 vec2f(start_x, start_y),
3259 vec2f(
3260 start_x + branch_style.width,
3261 if is_last { end_y } else { bounds.max_y() },
3262 ),
3263 ),
3264 background: Some(branch_style.color),
3265 border: gpui::Border::default(),
3266 corner_radii: (0.).into(),
3267 });
3268 cx.scene().push_quad(gpui::Quad {
3269 bounds: RectF::from_points(
3270 vec2f(start_x, end_y),
3271 vec2f(end_x, end_y + branch_style.width),
3272 ),
3273 background: Some(branch_style.color),
3274 border: gpui::Border::default(),
3275 corner_radii: (0.).into(),
3276 });
3277 })
3278 })
3279 .constrained()
3280 .with_width(size.x())
3281}
3282
3283impl View for CollabPanel {
3284 fn ui_name() -> &'static str {
3285 "CollabPanel"
3286 }
3287
3288 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
3289 if !self.has_focus {
3290 self.has_focus = true;
3291 if !self.context_menu.is_focused(cx) {
3292 if let Some(editing_state) = &self.channel_editing_state {
3293 if editing_state.pending_name().is_none() {
3294 cx.focus(&self.channel_name_editor);
3295 } else {
3296 cx.focus(&self.filter_editor);
3297 }
3298 } else {
3299 cx.focus(&self.filter_editor);
3300 }
3301 }
3302 cx.emit(Event::Focus);
3303 }
3304 }
3305
3306 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
3307 self.has_focus = false;
3308 }
3309
3310 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
3311 let theme = &theme::current(cx).collab_panel;
3312
3313 if self.user_store.read(cx).current_user().is_none() {
3314 enum LogInButton {}
3315
3316 return Flex::column()
3317 .with_child(
3318 MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
3319 let button = theme.log_in_button.style_for(state);
3320 Label::new("Sign in to collaborate", button.text.clone())
3321 .aligned()
3322 .left()
3323 .contained()
3324 .with_style(button.container)
3325 })
3326 .on_click(MouseButton::Left, |_, this, cx| {
3327 let client = this.client.clone();
3328 cx.spawn(|_, cx| async move {
3329 client.authenticate_and_connect(true, &cx).await.log_err();
3330 })
3331 .detach();
3332 })
3333 .with_cursor_style(CursorStyle::PointingHand),
3334 )
3335 .contained()
3336 .with_style(theme.container)
3337 .into_any();
3338 }
3339
3340 enum PanelFocus {}
3341 MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
3342 Stack::new()
3343 .with_child(
3344 Flex::column()
3345 .with_child(
3346 Flex::row().with_child(
3347 ChildView::new(&self.filter_editor, cx)
3348 .contained()
3349 .with_style(theme.user_query_editor.container)
3350 .flex(1.0, true),
3351 ),
3352 )
3353 .with_child(List::new(self.list_state.clone()).flex(1., true).into_any())
3354 .contained()
3355 .with_style(theme.container)
3356 .into_any(),
3357 )
3358 .with_children(
3359 (!self.context_menu_on_selected)
3360 .then(|| ChildView::new(&self.context_menu, cx)),
3361 )
3362 .into_any()
3363 })
3364 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
3365 .into_any_named("collab panel")
3366 }
3367
3368 fn update_keymap_context(
3369 &self,
3370 keymap: &mut gpui::keymap_matcher::KeymapContext,
3371 _: &AppContext,
3372 ) {
3373 Self::reset_to_default_keymap_context(keymap);
3374 if self.channel_editing_state.is_some() {
3375 keymap.add_identifier("editing");
3376 } else {
3377 keymap.add_identifier("not_editing");
3378 }
3379 }
3380}
3381
3382impl Panel for CollabPanel {
3383 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
3384 settings::get::<CollaborationPanelSettings>(cx).dock
3385 }
3386
3387 fn position_is_valid(&self, position: DockPosition) -> bool {
3388 matches!(position, DockPosition::Left | DockPosition::Right)
3389 }
3390
3391 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3392 settings::update_settings_file::<CollaborationPanelSettings>(
3393 self.fs.clone(),
3394 cx,
3395 move |settings| settings.dock = Some(position),
3396 );
3397 }
3398
3399 fn size(&self, cx: &gpui::WindowContext) -> f32 {
3400 self.width
3401 .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
3402 }
3403
3404 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
3405 self.width = size;
3406 self.serialize(cx);
3407 cx.notify();
3408 }
3409
3410 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
3411 settings::get::<CollaborationPanelSettings>(cx)
3412 .button
3413 .then(|| "icons/user_group_16.svg")
3414 }
3415
3416 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
3417 (
3418 "Collaboration Panel".to_string(),
3419 Some(Box::new(ToggleFocus)),
3420 )
3421 }
3422
3423 fn should_change_position_on_event(event: &Self::Event) -> bool {
3424 matches!(event, Event::DockPositionChanged)
3425 }
3426
3427 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
3428 self.has_focus
3429 }
3430
3431 fn is_focus_event(event: &Self::Event) -> bool {
3432 matches!(event, Event::Focus)
3433 }
3434}
3435
3436impl PartialEq for ListEntry {
3437 fn eq(&self, other: &Self) -> bool {
3438 match self {
3439 ListEntry::Header(section_1) => {
3440 if let ListEntry::Header(section_2) = other {
3441 return section_1 == section_2;
3442 }
3443 }
3444 ListEntry::CallParticipant { user: user_1, .. } => {
3445 if let ListEntry::CallParticipant { user: user_2, .. } = other {
3446 return user_1.id == user_2.id;
3447 }
3448 }
3449 ListEntry::ParticipantProject {
3450 project_id: project_id_1,
3451 ..
3452 } => {
3453 if let ListEntry::ParticipantProject {
3454 project_id: project_id_2,
3455 ..
3456 } = other
3457 {
3458 return project_id_1 == project_id_2;
3459 }
3460 }
3461 ListEntry::ParticipantScreen {
3462 peer_id: peer_id_1, ..
3463 } => {
3464 if let ListEntry::ParticipantScreen {
3465 peer_id: peer_id_2, ..
3466 } = other
3467 {
3468 return peer_id_1 == peer_id_2;
3469 }
3470 }
3471 ListEntry::Channel {
3472 channel: channel_1, ..
3473 } => {
3474 if let ListEntry::Channel {
3475 channel: channel_2, ..
3476 } = other
3477 {
3478 return channel_1.id == channel_2.id;
3479 }
3480 }
3481 ListEntry::ChannelNotes { channel_id } => {
3482 if let ListEntry::ChannelNotes {
3483 channel_id: other_id,
3484 } = other
3485 {
3486 return channel_id == other_id;
3487 }
3488 }
3489 ListEntry::ChannelChat { channel_id } => {
3490 if let ListEntry::ChannelChat {
3491 channel_id: other_id,
3492 } = other
3493 {
3494 return channel_id == other_id;
3495 }
3496 }
3497 ListEntry::ChannelInvite(channel_1) => {
3498 if let ListEntry::ChannelInvite(channel_2) = other {
3499 return channel_1.id == channel_2.id;
3500 }
3501 }
3502 ListEntry::IncomingRequest(user_1) => {
3503 if let ListEntry::IncomingRequest(user_2) = other {
3504 return user_1.id == user_2.id;
3505 }
3506 }
3507 ListEntry::OutgoingRequest(user_1) => {
3508 if let ListEntry::OutgoingRequest(user_2) = other {
3509 return user_1.id == user_2.id;
3510 }
3511 }
3512 ListEntry::Contact {
3513 contact: contact_1, ..
3514 } => {
3515 if let ListEntry::Contact {
3516 contact: contact_2, ..
3517 } = other
3518 {
3519 return contact_1.user.id == contact_2.user.id;
3520 }
3521 }
3522 ListEntry::ChannelEditor { depth } => {
3523 if let ListEntry::ChannelEditor { depth: other_depth } = other {
3524 return depth == other_depth;
3525 }
3526 }
3527 ListEntry::ContactPlaceholder => {
3528 if let ListEntry::ContactPlaceholder = other {
3529 return true;
3530 }
3531 }
3532 }
3533 false
3534 }
3535}
3536
3537fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
3538 Svg::new(svg_path)
3539 .with_color(style.color)
3540 .constrained()
3541 .with_width(style.icon_width)
3542 .aligned()
3543 .constrained()
3544 .with_width(style.button_width)
3545 .with_height(style.button_width)
3546 .contained()
3547 .with_style(style.container)
3548}