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