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