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 if is_active {
1980 "Open channel notes"
1981 } else {
1982 "Join channel"
1983 },
1984 None,
1985 theme.tooltip.clone(),
1986 cx,
1987 ),
1988 )
1989 .with_children({
1990 let participants =
1991 self.channel_store.read(cx).channel_participants(channel_id);
1992
1993 if !participants.is_empty() {
1994 let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
1995
1996 let result = FacePile::new(collab_theme.face_overlap)
1997 .with_children(
1998 participants
1999 .iter()
2000 .filter_map(|user| {
2001 Some(
2002 Image::from_data(user.avatar.clone()?)
2003 .with_style(collab_theme.channel_avatar),
2004 )
2005 })
2006 .take(FACEPILE_LIMIT),
2007 )
2008 .with_children((extra_count > 0).then(|| {
2009 Label::new(
2010 format!("+{}", extra_count),
2011 collab_theme.extra_participant_label.text.clone(),
2012 )
2013 .contained()
2014 .with_style(collab_theme.extra_participant_label.container)
2015 }));
2016
2017 Some(result)
2018 } else {
2019 None
2020 }
2021 })
2022 .with_spacing(8.)
2023 .align_children_center()
2024 .flex(1., true)
2025 })
2026 .with_child(
2027 MouseEventHandler::new::<ChannelNote, _>(ix, cx, move |mouse_state, _| {
2028 let container_style = collab_theme
2029 .disclosure
2030 .button
2031 .style_for(mouse_state)
2032 .container;
2033
2034 if channel.unseen_message_id.is_some() {
2035 Svg::new("icons/conversations.svg")
2036 .with_color(collab_theme.channel_note_active_color)
2037 .constrained()
2038 .with_width(collab_theme.channel_hash.width)
2039 .contained()
2040 .with_style(container_style)
2041 .with_uniform_padding(4.)
2042 .into_any()
2043 } else if row_hovered {
2044 Svg::new("icons/conversations.svg")
2045 .with_color(collab_theme.channel_hash.color)
2046 .constrained()
2047 .with_width(collab_theme.channel_hash.width)
2048 .contained()
2049 .with_style(container_style)
2050 .with_uniform_padding(4.)
2051 .into_any()
2052 } else {
2053 Empty::new().into_any()
2054 }
2055 })
2056 .on_click(MouseButton::Left, move |_, this, cx| {
2057 this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
2058 })
2059 .with_tooltip::<ChatTooltip>(
2060 ix,
2061 "Open channel chat",
2062 None,
2063 theme.tooltip.clone(),
2064 cx,
2065 )
2066 .contained()
2067 .with_margin_right(4.),
2068 )
2069 .with_child(
2070 MouseEventHandler::new::<ChannelCall, _>(ix, cx, move |mouse_state, cx| {
2071 let container_style = collab_theme
2072 .disclosure
2073 .button
2074 .style_for(mouse_state)
2075 .container;
2076 if row_hovered || channel.unseen_note_version.is_some() {
2077 Svg::new("icons/file.svg")
2078 .with_color(if channel.unseen_note_version.is_some() {
2079 collab_theme.channel_note_active_color
2080 } else {
2081 collab_theme.channel_hash.color
2082 })
2083 .constrained()
2084 .with_width(collab_theme.channel_hash.width)
2085 .contained()
2086 .with_style(container_style)
2087 .with_uniform_padding(4.)
2088 .with_margin_right(collab_theme.channel_hash.container.margin.left)
2089 .with_tooltip::<NotesTooltip>(
2090 ix as usize,
2091 "Open channel notes",
2092 None,
2093 theme.tooltip.clone(),
2094 cx,
2095 )
2096 .into_any()
2097 } else if has_messages_notification {
2098 Empty::new()
2099 .constrained()
2100 .with_width(collab_theme.channel_hash.width)
2101 .contained()
2102 .with_uniform_padding(4.)
2103 .with_margin_right(collab_theme.channel_hash.container.margin.left)
2104 .into_any()
2105 } else {
2106 Empty::new().into_any()
2107 }
2108 })
2109 .on_click(MouseButton::Left, move |_, this, cx| {
2110 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
2111 }),
2112 )
2113 .align_children_center()
2114 .styleable_component()
2115 .disclosable(
2116 disclosed,
2117 Box::new(ToggleCollapse {
2118 location: path.clone(),
2119 }),
2120 )
2121 .with_id(ix)
2122 .with_style(collab_theme.disclosure.clone())
2123 .element()
2124 .constrained()
2125 .with_height(collab_theme.row_height)
2126 .contained()
2127 .with_style(select_state(
2128 collab_theme
2129 .channel_row
2130 .in_state(is_selected || is_active || is_dragged_over),
2131 ))
2132 .with_padding_left(
2133 collab_theme.channel_row.default_style().padding.left
2134 + collab_theme.channel_indent * depth as f32,
2135 )
2136 })
2137 .on_click(MouseButton::Left, move |_, this, cx| {
2138 if this.drag_target_channel.take().is_none() {
2139 if is_active {
2140 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
2141 } else {
2142 this.join_channel(channel_id, cx)
2143 }
2144 }
2145 })
2146 .on_click(MouseButton::Right, {
2147 let path = path.clone();
2148 move |e, this, cx| {
2149 this.deploy_channel_context_menu(Some(e.position), &path, ix, cx);
2150 }
2151 })
2152 .on_up(MouseButton::Left, move |e, this, cx| {
2153 if let Some((_, dragged_channel)) = cx
2154 .global::<DragAndDrop<Workspace>>()
2155 .currently_dragged::<DraggedChannel>(cx.window())
2156 {
2157 if e.modifiers.alt {
2158 this.channel_store.update(cx, |channel_store, cx| {
2159 channel_store
2160 .link_channel(dragged_channel.0.id, channel_id, cx)
2161 .detach_and_log_err(cx)
2162 })
2163 } else {
2164 this.channel_store.update(cx, |channel_store, cx| {
2165 match dragged_channel.1 {
2166 Some(parent_id) => channel_store.move_channel(
2167 dragged_channel.0.id,
2168 parent_id,
2169 channel_id,
2170 cx,
2171 ),
2172 None => {
2173 channel_store.link_channel(dragged_channel.0.id, channel_id, cx)
2174 }
2175 }
2176 .detach_and_log_err(cx)
2177 })
2178 }
2179 }
2180 })
2181 .on_move({
2182 let channel = channel.clone();
2183 let path = path.clone();
2184 move |_, this, cx| {
2185 if let Some((_, _dragged_channel)) =
2186 cx.global::<DragAndDrop<Workspace>>()
2187 .currently_dragged::<DraggedChannel>(cx.window())
2188 {
2189 match &this.drag_target_channel {
2190 Some(current_target)
2191 if current_target.0 == channel && current_target.1 == path =>
2192 {
2193 return
2194 }
2195 _ => {
2196 this.drag_target_channel = Some((channel.clone(), path.clone()));
2197 cx.notify();
2198 }
2199 }
2200 }
2201 }
2202 })
2203 .as_draggable(
2204 (channel.clone(), path.parent_id()),
2205 move |modifiers, (channel, _), cx: &mut ViewContext<Workspace>| {
2206 let theme = &theme::current(cx).collab_panel;
2207
2208 Flex::<Workspace>::row()
2209 .with_children(modifiers.alt.then(|| {
2210 Svg::new("icons/plus.svg")
2211 .with_color(theme.channel_hash.color)
2212 .constrained()
2213 .with_width(theme.channel_hash.width)
2214 .aligned()
2215 .left()
2216 }))
2217 .with_child(
2218 Svg::new("icons/hash.svg")
2219 .with_color(theme.channel_hash.color)
2220 .constrained()
2221 .with_width(theme.channel_hash.width)
2222 .aligned()
2223 .left(),
2224 )
2225 .with_child(
2226 Label::new(channel.name.clone(), theme.channel_name.text.clone())
2227 .contained()
2228 .with_style(theme.channel_name.container)
2229 .aligned()
2230 .left(),
2231 )
2232 .align_children_center()
2233 .contained()
2234 .with_background_color(
2235 theme
2236 .container
2237 .background_color
2238 .unwrap_or(gpui::color::Color::transparent_black()),
2239 )
2240 .contained()
2241 .with_padding_left(
2242 theme.channel_row.default_style().padding.left
2243 + theme.channel_indent * depth as f32,
2244 )
2245 .into_any()
2246 },
2247 )
2248 .with_cursor_style(CursorStyle::PointingHand)
2249 .into_any()
2250 }
2251
2252 fn render_channel_notes(
2253 &self,
2254 channel_id: ChannelId,
2255 theme: &theme::CollabPanel,
2256 is_selected: bool,
2257 ix: usize,
2258 cx: &mut ViewContext<Self>,
2259 ) -> AnyElement<Self> {
2260 enum ChannelNotes {}
2261 let host_avatar_width = theme
2262 .contact_avatar
2263 .width
2264 .or(theme.contact_avatar.height)
2265 .unwrap_or(0.);
2266
2267 MouseEventHandler::new::<ChannelNotes, _>(ix as usize, cx, |state, cx| {
2268 let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
2269 let row = theme.project_row.in_state(is_selected).style_for(state);
2270
2271 Flex::<Self>::row()
2272 .with_child(render_tree_branch(
2273 tree_branch,
2274 &row.name.text,
2275 true,
2276 vec2f(host_avatar_width, theme.row_height),
2277 cx.font_cache(),
2278 ))
2279 .with_child(
2280 Svg::new("icons/file.svg")
2281 .with_color(theme.channel_hash.color)
2282 .constrained()
2283 .with_width(theme.channel_hash.width)
2284 .aligned()
2285 .left(),
2286 )
2287 .with_child(
2288 Label::new("notes", theme.channel_name.text.clone())
2289 .contained()
2290 .with_style(theme.channel_name.container)
2291 .aligned()
2292 .left()
2293 .flex(1., true),
2294 )
2295 .constrained()
2296 .with_height(theme.row_height)
2297 .contained()
2298 .with_style(*theme.channel_row.style_for(is_selected, state))
2299 .with_padding_left(theme.channel_row.default_style().padding.left)
2300 })
2301 .on_click(MouseButton::Left, move |_, this, cx| {
2302 this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
2303 })
2304 .with_cursor_style(CursorStyle::PointingHand)
2305 .into_any()
2306 }
2307
2308 fn render_channel_invite(
2309 channel: Arc<Channel>,
2310 channel_store: ModelHandle<ChannelStore>,
2311 theme: &theme::CollabPanel,
2312 is_selected: bool,
2313 cx: &mut ViewContext<Self>,
2314 ) -> AnyElement<Self> {
2315 enum Decline {}
2316 enum Accept {}
2317
2318 let channel_id = channel.id;
2319 let is_invite_pending = channel_store
2320 .read(cx)
2321 .has_pending_channel_invite_response(&channel);
2322 let button_spacing = theme.contact_button_spacing;
2323
2324 Flex::row()
2325 .with_child(
2326 Svg::new("icons/hash.svg")
2327 .with_color(theme.channel_hash.color)
2328 .constrained()
2329 .with_width(theme.channel_hash.width)
2330 .aligned()
2331 .left(),
2332 )
2333 .with_child(
2334 Label::new(channel.name.clone(), theme.contact_username.text.clone())
2335 .contained()
2336 .with_style(theme.contact_username.container)
2337 .aligned()
2338 .left()
2339 .flex(1., true),
2340 )
2341 .with_child(
2342 MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
2343 let button_style = if is_invite_pending {
2344 &theme.disabled_button
2345 } else {
2346 theme.contact_button.style_for(mouse_state)
2347 };
2348 render_icon_button(button_style, "icons/x.svg").aligned()
2349 })
2350 .with_cursor_style(CursorStyle::PointingHand)
2351 .on_click(MouseButton::Left, move |_, this, cx| {
2352 this.respond_to_channel_invite(channel_id, false, cx);
2353 })
2354 .contained()
2355 .with_margin_right(button_spacing),
2356 )
2357 .with_child(
2358 MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
2359 let button_style = if is_invite_pending {
2360 &theme.disabled_button
2361 } else {
2362 theme.contact_button.style_for(mouse_state)
2363 };
2364 render_icon_button(button_style, "icons/check.svg")
2365 .aligned()
2366 .flex_float()
2367 })
2368 .with_cursor_style(CursorStyle::PointingHand)
2369 .on_click(MouseButton::Left, move |_, this, cx| {
2370 this.respond_to_channel_invite(channel_id, true, cx);
2371 }),
2372 )
2373 .constrained()
2374 .with_height(theme.row_height)
2375 .contained()
2376 .with_style(
2377 *theme
2378 .contact_row
2379 .in_state(is_selected)
2380 .style_for(&mut Default::default()),
2381 )
2382 .with_padding_left(
2383 theme.contact_row.default_style().padding.left + theme.channel_indent,
2384 )
2385 .into_any()
2386 }
2387
2388 fn render_contact_request(
2389 user: Arc<User>,
2390 user_store: ModelHandle<UserStore>,
2391 theme: &theme::CollabPanel,
2392 is_incoming: bool,
2393 is_selected: bool,
2394 cx: &mut ViewContext<Self>,
2395 ) -> AnyElement<Self> {
2396 enum Decline {}
2397 enum Accept {}
2398 enum Cancel {}
2399
2400 let mut row = Flex::row()
2401 .with_children(user.avatar.clone().map(|avatar| {
2402 Image::from_data(avatar)
2403 .with_style(theme.contact_avatar)
2404 .aligned()
2405 .left()
2406 }))
2407 .with_child(
2408 Label::new(
2409 user.github_login.clone(),
2410 theme.contact_username.text.clone(),
2411 )
2412 .contained()
2413 .with_style(theme.contact_username.container)
2414 .aligned()
2415 .left()
2416 .flex(1., true),
2417 );
2418
2419 let user_id = user.id;
2420 let github_login = user.github_login.clone();
2421 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
2422 let button_spacing = theme.contact_button_spacing;
2423
2424 if is_incoming {
2425 row.add_child(
2426 MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
2427 let button_style = if is_contact_request_pending {
2428 &theme.disabled_button
2429 } else {
2430 theme.contact_button.style_for(mouse_state)
2431 };
2432 render_icon_button(button_style, "icons/x.svg").aligned()
2433 })
2434 .with_cursor_style(CursorStyle::PointingHand)
2435 .on_click(MouseButton::Left, move |_, this, cx| {
2436 this.respond_to_contact_request(user_id, false, cx);
2437 })
2438 .contained()
2439 .with_margin_right(button_spacing),
2440 );
2441
2442 row.add_child(
2443 MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
2444 let button_style = if is_contact_request_pending {
2445 &theme.disabled_button
2446 } else {
2447 theme.contact_button.style_for(mouse_state)
2448 };
2449 render_icon_button(button_style, "icons/check.svg")
2450 .aligned()
2451 .flex_float()
2452 })
2453 .with_cursor_style(CursorStyle::PointingHand)
2454 .on_click(MouseButton::Left, move |_, this, cx| {
2455 this.respond_to_contact_request(user_id, true, cx);
2456 }),
2457 );
2458 } else {
2459 row.add_child(
2460 MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
2461 let button_style = if is_contact_request_pending {
2462 &theme.disabled_button
2463 } else {
2464 theme.contact_button.style_for(mouse_state)
2465 };
2466 render_icon_button(button_style, "icons/x.svg")
2467 .aligned()
2468 .flex_float()
2469 })
2470 .with_padding(Padding::uniform(2.))
2471 .with_cursor_style(CursorStyle::PointingHand)
2472 .on_click(MouseButton::Left, move |_, this, cx| {
2473 this.remove_contact(user_id, &github_login, cx);
2474 })
2475 .flex_float(),
2476 );
2477 }
2478
2479 row.constrained()
2480 .with_height(theme.row_height)
2481 .contained()
2482 .with_style(
2483 *theme
2484 .contact_row
2485 .in_state(is_selected)
2486 .style_for(&mut Default::default()),
2487 )
2488 .into_any()
2489 }
2490
2491 fn has_subchannels(&self, ix: usize) -> bool {
2492 self.entries
2493 .get(ix)
2494 .zip(self.entries.get(ix + 1))
2495 .map(|entries| match entries {
2496 (
2497 ListEntry::Channel {
2498 path: this_path, ..
2499 },
2500 ListEntry::Channel {
2501 path: next_path, ..
2502 },
2503 ) => next_path.starts_with(this_path),
2504 _ => false,
2505 })
2506 .unwrap_or(false)
2507 }
2508
2509 fn deploy_channel_context_menu(
2510 &mut self,
2511 position: Option<Vector2F>,
2512 path: &ChannelPath,
2513 ix: usize,
2514 cx: &mut ViewContext<Self>,
2515 ) {
2516 self.context_menu_on_selected = position.is_none();
2517
2518 let channel_name = self.channel_clipboard.as_ref().and_then(|channel| {
2519 let channel_name = self
2520 .channel_store
2521 .read(cx)
2522 .channel_for_id(channel.channel_id)
2523 .map(|channel| channel.name.clone())?;
2524 Some(channel_name)
2525 });
2526
2527 self.context_menu.update(cx, |context_menu, cx| {
2528 context_menu.set_position_mode(if self.context_menu_on_selected {
2529 OverlayPositionMode::Local
2530 } else {
2531 OverlayPositionMode::Window
2532 });
2533
2534 let mut items = Vec::new();
2535
2536 let select_action_name = if self.selection == Some(ix) {
2537 "Unselect"
2538 } else {
2539 "Select"
2540 };
2541
2542 items.push(ContextMenuItem::action(
2543 select_action_name,
2544 ToggleSelectedIx { ix },
2545 ));
2546
2547 if self.has_subchannels(ix) {
2548 let expand_action_name = if self.is_channel_collapsed(&path) {
2549 "Expand Subchannels"
2550 } else {
2551 "Collapse Subchannels"
2552 };
2553 items.push(ContextMenuItem::action(
2554 expand_action_name,
2555 ToggleCollapse {
2556 location: path.clone(),
2557 },
2558 ));
2559 }
2560
2561 items.push(ContextMenuItem::action(
2562 "Open Notes",
2563 OpenChannelNotes {
2564 channel_id: path.channel_id(),
2565 },
2566 ));
2567
2568 items.push(ContextMenuItem::action(
2569 "Open Chat",
2570 JoinChannelChat {
2571 channel_id: path.channel_id(),
2572 },
2573 ));
2574
2575 if self.channel_store.read(cx).is_user_admin(path.channel_id()) {
2576 let parent_id = path.parent_id();
2577
2578 items.extend([
2579 ContextMenuItem::Separator,
2580 ContextMenuItem::action(
2581 "New Subchannel",
2582 NewChannel {
2583 location: path.clone(),
2584 },
2585 ),
2586 ContextMenuItem::action(
2587 "Rename",
2588 RenameChannel {
2589 location: path.clone(),
2590 },
2591 ),
2592 ContextMenuItem::Separator,
2593 ]);
2594
2595 if let Some(parent_id) = parent_id {
2596 items.push(ContextMenuItem::action(
2597 "Unlink from parent",
2598 UnlinkChannel {
2599 channel_id: path.channel_id(),
2600 parent_id,
2601 },
2602 ));
2603 }
2604
2605 items.extend([
2606 ContextMenuItem::action(
2607 "Move this channel",
2608 StartMoveChannelFor {
2609 channel_id: path.channel_id(),
2610 parent_id,
2611 },
2612 ),
2613 ContextMenuItem::action(
2614 "Link this channel",
2615 StartLinkChannelFor {
2616 channel_id: path.channel_id(),
2617 parent_id,
2618 },
2619 ),
2620 ]);
2621
2622 if let Some(channel_name) = channel_name {
2623 items.push(ContextMenuItem::Separator);
2624 items.push(ContextMenuItem::action(
2625 format!("Move '#{}' here", channel_name),
2626 MoveChannel {
2627 to: path.channel_id(),
2628 },
2629 ));
2630 items.push(ContextMenuItem::action(
2631 format!("Link '#{}' here", channel_name),
2632 LinkChannel {
2633 to: path.channel_id(),
2634 },
2635 ));
2636 }
2637
2638 items.extend([
2639 ContextMenuItem::Separator,
2640 ContextMenuItem::action(
2641 "Invite Members",
2642 InviteMembers {
2643 channel_id: path.channel_id(),
2644 },
2645 ),
2646 ContextMenuItem::action(
2647 "Manage Members",
2648 ManageMembers {
2649 channel_id: path.channel_id(),
2650 },
2651 ),
2652 ContextMenuItem::Separator,
2653 ContextMenuItem::action(
2654 "Delete",
2655 RemoveChannel {
2656 channel_id: path.channel_id(),
2657 },
2658 ),
2659 ]);
2660 }
2661
2662 context_menu.show(
2663 position.unwrap_or_default(),
2664 if self.context_menu_on_selected {
2665 gpui::elements::AnchorCorner::TopRight
2666 } else {
2667 gpui::elements::AnchorCorner::BottomLeft
2668 },
2669 items,
2670 cx,
2671 );
2672 });
2673
2674 cx.notify();
2675 }
2676
2677 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
2678 if self.take_editing_state(cx) {
2679 cx.focus(&self.filter_editor);
2680 } else {
2681 self.filter_editor.update(cx, |editor, cx| {
2682 if editor.buffer().read(cx).len(cx) > 0 {
2683 editor.set_text("", cx);
2684 }
2685 });
2686 }
2687
2688 self.update_entries(false, cx);
2689 }
2690
2691 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
2692 let ix = self.selection.map_or(0, |ix| ix + 1);
2693 if ix < self.entries.len() {
2694 self.selection = Some(ix);
2695 }
2696
2697 self.list_state.reset(self.entries.len());
2698 if let Some(ix) = self.selection {
2699 self.list_state.scroll_to(ListOffset {
2700 item_ix: ix,
2701 offset_in_item: 0.,
2702 });
2703 }
2704 cx.notify();
2705 }
2706
2707 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
2708 let ix = self.selection.take().unwrap_or(0);
2709 if ix > 0 {
2710 self.selection = Some(ix - 1);
2711 }
2712
2713 self.list_state.reset(self.entries.len());
2714 if let Some(ix) = self.selection {
2715 self.list_state.scroll_to(ListOffset {
2716 item_ix: ix,
2717 offset_in_item: 0.,
2718 });
2719 }
2720 cx.notify();
2721 }
2722
2723 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
2724 if self.confirm_channel_edit(cx) {
2725 return;
2726 }
2727
2728 if let Some(selection) = self.selection {
2729 if let Some(entry) = self.entries.get(selection) {
2730 match entry {
2731 ListEntry::Header(section) => match section {
2732 Section::ActiveCall => Self::leave_call(cx),
2733 Section::Channels => self.new_root_channel(cx),
2734 Section::Contacts => self.toggle_contact_finder(cx),
2735 Section::ContactRequests
2736 | Section::Online
2737 | Section::Offline
2738 | Section::ChannelInvites => {
2739 self.toggle_section_expanded(*section, cx);
2740 }
2741 },
2742 ListEntry::Contact { contact, calling } => {
2743 if contact.online && !contact.busy && !calling {
2744 self.call(contact.user.id, Some(self.project.clone()), cx);
2745 }
2746 }
2747 ListEntry::ParticipantProject {
2748 project_id,
2749 host_user_id,
2750 ..
2751 } => {
2752 if let Some(workspace) = self.workspace.upgrade(cx) {
2753 let app_state = workspace.read(cx).app_state().clone();
2754 workspace::join_remote_project(
2755 *project_id,
2756 *host_user_id,
2757 app_state,
2758 cx,
2759 )
2760 .detach_and_log_err(cx);
2761 }
2762 }
2763 ListEntry::ParticipantScreen { peer_id, .. } => {
2764 if let Some(workspace) = self.workspace.upgrade(cx) {
2765 workspace.update(cx, |workspace, cx| {
2766 workspace.open_shared_screen(*peer_id, cx)
2767 });
2768 }
2769 }
2770 ListEntry::Channel { channel, .. } => {
2771 let is_active = iife!({
2772 let call_channel = ActiveCall::global(cx)
2773 .read(cx)
2774 .room()?
2775 .read(cx)
2776 .channel_id()?;
2777
2778 Some(call_channel == channel.id)
2779 })
2780 .unwrap_or(false);
2781 if is_active {
2782 self.open_channel_notes(
2783 &OpenChannelNotes {
2784 channel_id: channel.id,
2785 },
2786 cx,
2787 )
2788 } else {
2789 self.join_channel(channel.id, cx)
2790 }
2791 }
2792 ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
2793 _ => {}
2794 }
2795 }
2796 }
2797 }
2798
2799 fn insert_space(&mut self, _: &InsertSpace, cx: &mut ViewContext<Self>) {
2800 if self.channel_editing_state.is_some() {
2801 self.channel_name_editor.update(cx, |editor, cx| {
2802 editor.insert(" ", cx);
2803 });
2804 }
2805 }
2806
2807 fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
2808 if let Some(editing_state) = &mut self.channel_editing_state {
2809 match editing_state {
2810 ChannelEditingState::Create {
2811 location,
2812 pending_name,
2813 ..
2814 } => {
2815 if pending_name.is_some() {
2816 return false;
2817 }
2818 let channel_name = self.channel_name_editor.read(cx).text(cx);
2819
2820 *pending_name = Some(channel_name.clone());
2821
2822 self.channel_store
2823 .update(cx, |channel_store, cx| {
2824 channel_store.create_channel(
2825 &channel_name,
2826 location.as_ref().map(|location| location.channel_id()),
2827 cx,
2828 )
2829 })
2830 .detach();
2831 cx.notify();
2832 }
2833 ChannelEditingState::Rename {
2834 location,
2835 pending_name,
2836 } => {
2837 if pending_name.is_some() {
2838 return false;
2839 }
2840 let channel_name = self.channel_name_editor.read(cx).text(cx);
2841 *pending_name = Some(channel_name.clone());
2842
2843 self.channel_store
2844 .update(cx, |channel_store, cx| {
2845 channel_store.rename(location.channel_id(), &channel_name, cx)
2846 })
2847 .detach();
2848 cx.notify();
2849 }
2850 }
2851 cx.focus_self();
2852 true
2853 } else {
2854 false
2855 }
2856 }
2857
2858 fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
2859 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
2860 self.collapsed_sections.remove(ix);
2861 } else {
2862 self.collapsed_sections.push(section);
2863 }
2864 self.update_entries(false, cx);
2865 }
2866
2867 fn collapse_selected_channel(
2868 &mut self,
2869 _: &CollapseSelectedChannel,
2870 cx: &mut ViewContext<Self>,
2871 ) {
2872 let Some((_, path)) = self
2873 .selected_channel()
2874 .map(|(channel, parent)| (channel.id, parent))
2875 else {
2876 return;
2877 };
2878
2879 if self.is_channel_collapsed(&path) {
2880 return;
2881 }
2882
2883 self.toggle_channel_collapsed(&path.clone(), cx);
2884 }
2885
2886 fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
2887 let Some((_, path)) = self
2888 .selected_channel()
2889 .map(|(channel, parent)| (channel.id, parent))
2890 else {
2891 return;
2892 };
2893
2894 if !self.is_channel_collapsed(&path) {
2895 return;
2896 }
2897
2898 self.toggle_channel_collapsed(path.to_owned(), cx)
2899 }
2900
2901 fn toggle_channel_collapsed_action(
2902 &mut self,
2903 action: &ToggleCollapse,
2904 cx: &mut ViewContext<Self>,
2905 ) {
2906 self.toggle_channel_collapsed(&action.location, cx);
2907 }
2908
2909 fn toggle_channel_collapsed<'a>(
2910 &mut self,
2911 path: impl Into<Cow<'a, ChannelPath>>,
2912 cx: &mut ViewContext<Self>,
2913 ) {
2914 let path = path.into();
2915 match self.collapsed_channels.binary_search(&path) {
2916 Ok(ix) => {
2917 self.collapsed_channels.remove(ix);
2918 }
2919 Err(ix) => {
2920 self.collapsed_channels.insert(ix, path.into_owned());
2921 }
2922 };
2923 self.serialize(cx);
2924 self.update_entries(true, cx);
2925 cx.notify();
2926 cx.focus_self();
2927 }
2928
2929 fn is_channel_collapsed(&self, path: &ChannelPath) -> bool {
2930 self.collapsed_channels.binary_search(path).is_ok()
2931 }
2932
2933 fn leave_call(cx: &mut ViewContext<Self>) {
2934 ActiveCall::global(cx)
2935 .update(cx, |call, cx| call.hang_up(cx))
2936 .detach_and_log_err(cx);
2937 }
2938
2939 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
2940 if let Some(workspace) = self.workspace.upgrade(cx) {
2941 workspace.update(cx, |workspace, cx| {
2942 workspace.toggle_modal(cx, |_, cx| {
2943 cx.add_view(|cx| {
2944 let mut finder = ContactFinder::new(self.user_store.clone(), cx);
2945 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
2946 finder
2947 })
2948 });
2949 });
2950 }
2951 }
2952
2953 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
2954 self.channel_editing_state = Some(ChannelEditingState::Create {
2955 location: None,
2956 pending_name: None,
2957 });
2958 self.update_entries(false, cx);
2959 self.select_channel_editor();
2960 cx.focus(self.channel_name_editor.as_any());
2961 cx.notify();
2962 }
2963
2964 fn select_channel_editor(&mut self) {
2965 self.selection = self.entries.iter().position(|entry| match entry {
2966 ListEntry::ChannelEditor { .. } => true,
2967 _ => false,
2968 });
2969 }
2970
2971 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
2972 self.collapsed_channels
2973 .retain(|channel| *channel != action.location);
2974 self.channel_editing_state = Some(ChannelEditingState::Create {
2975 location: Some(action.location.to_owned()),
2976 pending_name: None,
2977 });
2978 self.update_entries(false, cx);
2979 self.select_channel_editor();
2980 cx.focus(self.channel_name_editor.as_any());
2981 cx.notify();
2982 }
2983
2984 fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
2985 self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
2986 }
2987
2988 fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
2989 self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
2990 }
2991
2992 fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
2993 if let Some((channel, _)) = self.selected_channel() {
2994 self.remove_channel(channel.id, cx)
2995 }
2996 }
2997
2998 fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
2999 if let Some((_, parent)) = self.selected_channel() {
3000 self.rename_channel(
3001 &RenameChannel {
3002 location: parent.to_owned(),
3003 },
3004 cx,
3005 );
3006 }
3007 }
3008
3009 fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
3010 let channel_store = self.channel_store.read(cx);
3011 if !channel_store.is_user_admin(action.location.channel_id()) {
3012 return;
3013 }
3014 if let Some(channel) = channel_store
3015 .channel_for_id(action.location.channel_id())
3016 .cloned()
3017 {
3018 self.channel_editing_state = Some(ChannelEditingState::Rename {
3019 location: action.location.to_owned(),
3020 pending_name: None,
3021 });
3022 self.channel_name_editor.update(cx, |editor, cx| {
3023 editor.set_text(channel.name.clone(), cx);
3024 editor.select_all(&Default::default(), cx);
3025 });
3026 cx.focus(self.channel_name_editor.as_any());
3027 self.update_entries(false, cx);
3028 self.select_channel_editor();
3029 }
3030 }
3031
3032 fn open_channel_notes(&mut self, action: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
3033 if let Some(workspace) = self.workspace.upgrade(cx) {
3034 ChannelView::open(action.channel_id, workspace, cx).detach();
3035 }
3036 }
3037
3038 fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
3039 let Some((_, path)) = self.selected_channel() else {
3040 return;
3041 };
3042
3043 self.deploy_channel_context_menu(None, &path.to_owned(), self.selection.unwrap(), cx);
3044 }
3045
3046 fn selected_channel(&self) -> Option<(&Arc<Channel>, &ChannelPath)> {
3047 self.selection
3048 .and_then(|ix| self.entries.get(ix))
3049 .and_then(|entry| match entry {
3050 ListEntry::Channel {
3051 channel,
3052 path: parent,
3053 ..
3054 } => Some((channel, parent)),
3055 _ => None,
3056 })
3057 }
3058
3059 fn show_channel_modal(
3060 &mut self,
3061 channel_id: ChannelId,
3062 mode: channel_modal::Mode,
3063 cx: &mut ViewContext<Self>,
3064 ) {
3065 let workspace = self.workspace.clone();
3066 let user_store = self.user_store.clone();
3067 let channel_store = self.channel_store.clone();
3068 let members = self.channel_store.update(cx, |channel_store, cx| {
3069 channel_store.get_channel_member_details(channel_id, cx)
3070 });
3071
3072 cx.spawn(|_, mut cx| async move {
3073 let members = members.await?;
3074 workspace.update(&mut cx, |workspace, cx| {
3075 workspace.toggle_modal(cx, |_, cx| {
3076 cx.add_view(|cx| {
3077 ChannelModal::new(
3078 user_store.clone(),
3079 channel_store.clone(),
3080 channel_id,
3081 mode,
3082 members,
3083 cx,
3084 )
3085 })
3086 });
3087 })
3088 })
3089 .detach();
3090 }
3091
3092 fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
3093 self.remove_channel(action.channel_id, cx)
3094 }
3095
3096 fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
3097 let channel_store = self.channel_store.clone();
3098 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
3099 let prompt_message = format!(
3100 "Are you sure you want to remove the channel \"{}\"?",
3101 channel.name
3102 );
3103 let mut answer =
3104 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
3105 let window = cx.window();
3106 cx.spawn(|this, mut cx| async move {
3107 if answer.next().await == Some(0) {
3108 if let Err(e) = channel_store
3109 .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
3110 .await
3111 {
3112 window.prompt(
3113 PromptLevel::Info,
3114 &format!("Failed to remove channel: {}", e),
3115 &["Ok"],
3116 &mut cx,
3117 );
3118 }
3119 this.update(&mut cx, |_, cx| cx.focus_self()).ok();
3120 }
3121 })
3122 .detach();
3123 }
3124 }
3125
3126 // Should move to the filter editor if clicking on it
3127 // Should move selection to the channel editor if activating it
3128
3129 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
3130 let user_store = self.user_store.clone();
3131 let prompt_message = format!(
3132 "Are you sure you want to remove \"{}\" from your contacts?",
3133 github_login
3134 );
3135 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
3136 let window = cx.window();
3137 cx.spawn(|_, mut cx| async move {
3138 if answer.next().await == Some(0) {
3139 if let Err(e) = user_store
3140 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
3141 .await
3142 {
3143 window.prompt(
3144 PromptLevel::Info,
3145 &format!("Failed to remove contact: {}", e),
3146 &["Ok"],
3147 &mut cx,
3148 );
3149 }
3150 }
3151 })
3152 .detach();
3153 }
3154
3155 fn respond_to_contact_request(
3156 &mut self,
3157 user_id: u64,
3158 accept: bool,
3159 cx: &mut ViewContext<Self>,
3160 ) {
3161 self.user_store
3162 .update(cx, |store, cx| {
3163 store.respond_to_contact_request(user_id, accept, cx)
3164 })
3165 .detach();
3166 }
3167
3168 fn respond_to_channel_invite(
3169 &mut self,
3170 channel_id: u64,
3171 accept: bool,
3172 cx: &mut ViewContext<Self>,
3173 ) {
3174 let respond = self.channel_store.update(cx, |store, _| {
3175 store.respond_to_channel_invite(channel_id, accept)
3176 });
3177 cx.foreground().spawn(respond).detach();
3178 }
3179
3180 fn call(
3181 &mut self,
3182 recipient_user_id: u64,
3183 initial_project: Option<ModelHandle<Project>>,
3184 cx: &mut ViewContext<Self>,
3185 ) {
3186 ActiveCall::global(cx)
3187 .update(cx, |call, cx| {
3188 call.invite(recipient_user_id, initial_project, cx)
3189 })
3190 .detach_and_log_err(cx);
3191 }
3192
3193 fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
3194 let workspace = self.workspace.clone();
3195 let window = cx.window();
3196 let active_call = ActiveCall::global(cx);
3197 cx.spawn(|_, mut cx| async move {
3198 if active_call.read_with(&mut cx, |active_call, cx| {
3199 if let Some(room) = active_call.room() {
3200 let room = room.read(cx);
3201 room.is_sharing_project() && room.remote_participants().len() > 0
3202 } else {
3203 false
3204 }
3205 }) {
3206 let answer = window.prompt(
3207 PromptLevel::Warning,
3208 "Leaving this call will unshare your current project.\nDo you want to switch channels?",
3209 &["Yes, Join Channel", "Cancel"],
3210 &mut cx,
3211 );
3212
3213 if let Some(mut answer) = answer {
3214 if answer.next().await == Some(1) {
3215 return anyhow::Ok(());
3216 }
3217 }
3218 }
3219
3220 let room = active_call
3221 .update(&mut cx, |call, cx| call.join_channel(channel_id, cx))
3222 .await?;
3223
3224 let task = room.update(&mut cx, |room, cx| {
3225 let workspace = workspace.upgrade(cx)?;
3226 let (project, host) = room.most_active_project()?;
3227 let app_state = workspace.read(cx).app_state().clone();
3228 Some(workspace::join_remote_project(project, host, app_state, cx))
3229 });
3230 if let Some(task) = task {
3231 task.await?;
3232 }
3233
3234 anyhow::Ok(())
3235 })
3236 .detach_and_log_err(cx);
3237 }
3238
3239 fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
3240 let channel_id = action.channel_id;
3241 if let Some(workspace) = self.workspace.upgrade(cx) {
3242 cx.app_context().defer(move |cx| {
3243 workspace.update(cx, |workspace, cx| {
3244 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
3245 panel.update(cx, |panel, cx| {
3246 panel.select_channel(channel_id, cx).detach_and_log_err(cx);
3247 });
3248 }
3249 });
3250 });
3251 }
3252 }
3253}
3254
3255fn render_tree_branch(
3256 branch_style: theme::TreeBranch,
3257 row_style: &TextStyle,
3258 is_last: bool,
3259 size: Vector2F,
3260 font_cache: &FontCache,
3261) -> gpui::elements::ConstrainedBox<CollabPanel> {
3262 let line_height = row_style.line_height(font_cache);
3263 let cap_height = row_style.cap_height(font_cache);
3264 let baseline_offset = row_style.baseline_offset(font_cache) + (size.y() - line_height) / 2.;
3265
3266 Canvas::new(move |bounds, _, _, cx| {
3267 cx.paint_layer(None, |cx| {
3268 let start_x = bounds.min_x() + (bounds.width() / 2.) - (branch_style.width / 2.);
3269 let end_x = bounds.max_x();
3270 let start_y = bounds.min_y();
3271 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
3272
3273 cx.scene().push_quad(gpui::Quad {
3274 bounds: RectF::from_points(
3275 vec2f(start_x, start_y),
3276 vec2f(
3277 start_x + branch_style.width,
3278 if is_last { end_y } else { bounds.max_y() },
3279 ),
3280 ),
3281 background: Some(branch_style.color),
3282 border: gpui::Border::default(),
3283 corner_radii: (0.).into(),
3284 });
3285 cx.scene().push_quad(gpui::Quad {
3286 bounds: RectF::from_points(
3287 vec2f(start_x, end_y),
3288 vec2f(end_x, end_y + branch_style.width),
3289 ),
3290 background: Some(branch_style.color),
3291 border: gpui::Border::default(),
3292 corner_radii: (0.).into(),
3293 });
3294 })
3295 })
3296 .constrained()
3297 .with_width(size.x())
3298}
3299
3300impl View for CollabPanel {
3301 fn ui_name() -> &'static str {
3302 "CollabPanel"
3303 }
3304
3305 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
3306 if !self.has_focus {
3307 self.has_focus = true;
3308 if !self.context_menu.is_focused(cx) {
3309 if let Some(editing_state) = &self.channel_editing_state {
3310 if editing_state.pending_name().is_none() {
3311 cx.focus(&self.channel_name_editor);
3312 } else {
3313 cx.focus(&self.filter_editor);
3314 }
3315 } else {
3316 cx.focus(&self.filter_editor);
3317 }
3318 }
3319 cx.emit(Event::Focus);
3320 }
3321 }
3322
3323 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
3324 self.has_focus = false;
3325 }
3326
3327 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
3328 let theme = &theme::current(cx).collab_panel;
3329
3330 if self.user_store.read(cx).current_user().is_none() {
3331 enum LogInButton {}
3332
3333 return Flex::column()
3334 .with_child(
3335 MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
3336 let button = theme.log_in_button.style_for(state);
3337 Label::new("Sign in to collaborate", button.text.clone())
3338 .aligned()
3339 .left()
3340 .contained()
3341 .with_style(button.container)
3342 })
3343 .on_click(MouseButton::Left, |_, this, cx| {
3344 let client = this.client.clone();
3345 cx.spawn(|_, cx| async move {
3346 client.authenticate_and_connect(true, &cx).await.log_err();
3347 })
3348 .detach();
3349 })
3350 .with_cursor_style(CursorStyle::PointingHand),
3351 )
3352 .contained()
3353 .with_style(theme.container)
3354 .into_any();
3355 }
3356
3357 enum PanelFocus {}
3358 MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
3359 Stack::new()
3360 .with_child(
3361 Flex::column()
3362 .with_child(
3363 Flex::row().with_child(
3364 ChildView::new(&self.filter_editor, cx)
3365 .contained()
3366 .with_style(theme.user_query_editor.container)
3367 .flex(1.0, true),
3368 ),
3369 )
3370 .with_child(List::new(self.list_state.clone()).flex(1., true).into_any())
3371 .contained()
3372 .with_style(theme.container)
3373 .into_any(),
3374 )
3375 .with_children(
3376 (!self.context_menu_on_selected)
3377 .then(|| ChildView::new(&self.context_menu, cx)),
3378 )
3379 .into_any()
3380 })
3381 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
3382 .into_any_named("collab panel")
3383 }
3384
3385 fn update_keymap_context(
3386 &self,
3387 keymap: &mut gpui::keymap_matcher::KeymapContext,
3388 _: &AppContext,
3389 ) {
3390 Self::reset_to_default_keymap_context(keymap);
3391 if self.channel_editing_state.is_some() {
3392 keymap.add_identifier("editing");
3393 } else {
3394 keymap.add_identifier("not_editing");
3395 }
3396 }
3397}
3398
3399impl Panel for CollabPanel {
3400 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
3401 settings::get::<CollaborationPanelSettings>(cx).dock
3402 }
3403
3404 fn position_is_valid(&self, position: DockPosition) -> bool {
3405 matches!(position, DockPosition::Left | DockPosition::Right)
3406 }
3407
3408 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3409 settings::update_settings_file::<CollaborationPanelSettings>(
3410 self.fs.clone(),
3411 cx,
3412 move |settings| settings.dock = Some(position),
3413 );
3414 }
3415
3416 fn size(&self, cx: &gpui::WindowContext) -> f32 {
3417 self.width
3418 .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
3419 }
3420
3421 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
3422 self.width = size;
3423 self.serialize(cx);
3424 cx.notify();
3425 }
3426
3427 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
3428 settings::get::<CollaborationPanelSettings>(cx)
3429 .button
3430 .then(|| "icons/user_group_16.svg")
3431 }
3432
3433 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
3434 (
3435 "Collaboration Panel".to_string(),
3436 Some(Box::new(ToggleFocus)),
3437 )
3438 }
3439
3440 fn should_change_position_on_event(event: &Self::Event) -> bool {
3441 matches!(event, Event::DockPositionChanged)
3442 }
3443
3444 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
3445 self.has_focus
3446 }
3447
3448 fn is_focus_event(event: &Self::Event) -> bool {
3449 matches!(event, Event::Focus)
3450 }
3451}
3452
3453impl PartialEq for ListEntry {
3454 fn eq(&self, other: &Self) -> bool {
3455 match self {
3456 ListEntry::Header(section_1) => {
3457 if let ListEntry::Header(section_2) = other {
3458 return section_1 == section_2;
3459 }
3460 }
3461 ListEntry::CallParticipant { user: user_1, .. } => {
3462 if let ListEntry::CallParticipant { user: user_2, .. } = other {
3463 return user_1.id == user_2.id;
3464 }
3465 }
3466 ListEntry::ParticipantProject {
3467 project_id: project_id_1,
3468 ..
3469 } => {
3470 if let ListEntry::ParticipantProject {
3471 project_id: project_id_2,
3472 ..
3473 } = other
3474 {
3475 return project_id_1 == project_id_2;
3476 }
3477 }
3478 ListEntry::ParticipantScreen {
3479 peer_id: peer_id_1, ..
3480 } => {
3481 if let ListEntry::ParticipantScreen {
3482 peer_id: peer_id_2, ..
3483 } = other
3484 {
3485 return peer_id_1 == peer_id_2;
3486 }
3487 }
3488 ListEntry::Channel {
3489 channel: channel_1,
3490 depth: depth_1,
3491 path: parent_1,
3492 } => {
3493 if let ListEntry::Channel {
3494 channel: channel_2,
3495 depth: depth_2,
3496 path: parent_2,
3497 } = other
3498 {
3499 return channel_1.id == channel_2.id
3500 && depth_1 == depth_2
3501 && parent_1 == parent_2;
3502 }
3503 }
3504 ListEntry::ChannelNotes { channel_id } => {
3505 if let ListEntry::ChannelNotes {
3506 channel_id: other_id,
3507 } = other
3508 {
3509 return channel_id == other_id;
3510 }
3511 }
3512 ListEntry::ChannelInvite(channel_1) => {
3513 if let ListEntry::ChannelInvite(channel_2) = other {
3514 return channel_1.id == channel_2.id;
3515 }
3516 }
3517 ListEntry::IncomingRequest(user_1) => {
3518 if let ListEntry::IncomingRequest(user_2) = other {
3519 return user_1.id == user_2.id;
3520 }
3521 }
3522 ListEntry::OutgoingRequest(user_1) => {
3523 if let ListEntry::OutgoingRequest(user_2) = other {
3524 return user_1.id == user_2.id;
3525 }
3526 }
3527 ListEntry::Contact {
3528 contact: contact_1, ..
3529 } => {
3530 if let ListEntry::Contact {
3531 contact: contact_2, ..
3532 } = other
3533 {
3534 return contact_1.user.id == contact_2.user.id;
3535 }
3536 }
3537 ListEntry::ChannelEditor { depth } => {
3538 if let ListEntry::ChannelEditor { depth: other_depth } = other {
3539 return depth == other_depth;
3540 }
3541 }
3542 ListEntry::ContactPlaceholder => {
3543 if let ListEntry::ContactPlaceholder = other {
3544 return true;
3545 }
3546 }
3547 }
3548 false
3549 }
3550}
3551
3552fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
3553 Svg::new(svg_path)
3554 .with_color(style.color)
3555 .constrained()
3556 .with_width(style.icon_width)
3557 .aligned()
3558 .constrained()
3559 .with_width(style.button_width)
3560 .with_height(style.button_width)
3561 .contained()
3562 .with_style(style.container)
3563}