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