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