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