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