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