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