1mod channel_modal;
2mod contact_finder;
3mod panel_settings;
4
5use anyhow::Result;
6use call::ActiveCall;
7use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore};
8use contact_finder::build_contact_finder;
9use context_menu::{ContextMenu, ContextMenuItem};
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Cancel, Editor};
12use futures::StreamExt;
13use fuzzy::{match_strings, StringMatchCandidate};
14use gpui::{
15 actions,
16 elements::{
17 Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
18 MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg,
19 },
20 geometry::{
21 rect::RectF,
22 vector::{vec2f, Vector2F},
23 },
24 impl_actions,
25 platform::{CursorStyle, MouseButton, PromptLevel},
26 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
27 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
28};
29use menu::{Confirm, SelectNext, SelectPrev};
30use panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings};
31use project::{Fs, Project};
32use serde_derive::{Deserialize, Serialize};
33use settings::SettingsStore;
34use std::{mem, sync::Arc};
35use theme::IconButton;
36use util::{ResultExt, TryFutureExt};
37use workspace::{
38 dock::{DockPosition, Panel},
39 item::ItemHandle,
40 Workspace,
41};
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44struct RemoveChannel {
45 channel_id: u64,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49struct NewChannel {
50 channel_id: u64,
51}
52
53actions!(collab_panel, [ToggleFocus]);
54
55impl_actions!(collab_panel, [RemoveChannel, NewChannel]);
56
57const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
58
59pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
60 settings::register::<panel_settings::ChannelsPanelSettings>(cx);
61 contact_finder::init(cx);
62 channel_modal::init(cx);
63
64 cx.add_action(CollabPanel::cancel);
65 cx.add_action(CollabPanel::select_next);
66 cx.add_action(CollabPanel::select_prev);
67 cx.add_action(CollabPanel::confirm);
68 cx.add_action(CollabPanel::remove_channel);
69 cx.add_action(CollabPanel::new_subchannel);
70}
71
72#[derive(Debug, Default)]
73pub struct ChannelEditingState {
74 parent_id: Option<u64>,
75}
76
77pub struct CollabPanel {
78 width: Option<f32>,
79 fs: Arc<dyn Fs>,
80 has_focus: bool,
81 pending_serialization: Task<Option<()>>,
82 context_menu: ViewHandle<ContextMenu>,
83 filter_editor: ViewHandle<Editor>,
84 channel_name_editor: ViewHandle<Editor>,
85 channel_editing_state: Option<ChannelEditingState>,
86 entries: Vec<ListEntry>,
87 selection: Option<usize>,
88 user_store: ModelHandle<UserStore>,
89 channel_store: ModelHandle<ChannelStore>,
90 project: ModelHandle<Project>,
91 match_candidates: Vec<StringMatchCandidate>,
92 list_state: ListState<Self>,
93 subscriptions: Vec<Subscription>,
94 collapsed_sections: Vec<Section>,
95 workspace: WeakViewHandle<Workspace>,
96}
97
98#[derive(Serialize, Deserialize)]
99struct SerializedChannelsPanel {
100 width: Option<f32>,
101}
102
103#[derive(Debug)]
104pub enum Event {
105 DockPositionChanged,
106 Focus,
107 Dismissed,
108}
109
110#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
111enum Section {
112 ActiveCall,
113 Channels,
114 Requests,
115 Contacts,
116 Online,
117 Offline,
118}
119
120#[derive(Clone, Debug)]
121enum ListEntry {
122 Header(Section, usize),
123 CallParticipant {
124 user: Arc<User>,
125 is_pending: bool,
126 },
127 ParticipantProject {
128 project_id: u64,
129 worktree_root_names: Vec<String>,
130 host_user_id: u64,
131 is_last: bool,
132 },
133 ParticipantScreen {
134 peer_id: PeerId,
135 is_last: bool,
136 },
137 IncomingRequest(Arc<User>),
138 OutgoingRequest(Arc<User>),
139 ChannelInvite(Arc<Channel>),
140 Channel(Arc<Channel>),
141 ChannelEditor {
142 depth: usize,
143 },
144 Contact {
145 contact: Arc<Contact>,
146 calling: bool,
147 },
148}
149
150impl Entity for CollabPanel {
151 type Event = Event;
152}
153
154impl CollabPanel {
155 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
156 cx.add_view::<Self, _>(|cx| {
157 let view_id = cx.view_id();
158
159 let filter_editor = cx.add_view(|cx| {
160 let mut editor = Editor::single_line(
161 Some(Arc::new(|theme| {
162 theme.collab_panel.user_query_editor.clone()
163 })),
164 cx,
165 );
166 editor.set_placeholder_text("Filter channels, contacts", cx);
167 editor
168 });
169
170 cx.subscribe(&filter_editor, |this, _, event, cx| {
171 if let editor::Event::BufferEdited = event {
172 let query = this.filter_editor.read(cx).text(cx);
173 if !query.is_empty() {
174 this.selection.take();
175 }
176 this.update_entries(cx);
177 if !query.is_empty() {
178 this.selection = this
179 .entries
180 .iter()
181 .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
182 }
183 }
184 })
185 .detach();
186
187 let channel_name_editor = cx.add_view(|cx| {
188 Editor::single_line(
189 Some(Arc::new(|theme| {
190 theme.collab_panel.user_query_editor.clone()
191 })),
192 cx,
193 )
194 });
195
196 cx.subscribe(&channel_name_editor, |this, _, event, cx| {
197 if let editor::Event::Blurred = event {
198 this.take_editing_state(cx);
199 this.update_entries(cx);
200 cx.notify();
201 }
202 })
203 .detach();
204
205 let list_state =
206 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
207 let theme = theme::current(cx).clone();
208 let is_selected = this.selection == Some(ix);
209 let current_project_id = this.project.read(cx).remote_id();
210
211 match &this.entries[ix] {
212 ListEntry::Header(section, depth) => {
213 let is_collapsed = this.collapsed_sections.contains(section);
214 this.render_header(
215 *section,
216 &theme,
217 *depth,
218 is_selected,
219 is_collapsed,
220 cx,
221 )
222 }
223 ListEntry::CallParticipant { user, is_pending } => {
224 Self::render_call_participant(
225 user,
226 *is_pending,
227 is_selected,
228 &theme.collab_panel,
229 )
230 }
231 ListEntry::ParticipantProject {
232 project_id,
233 worktree_root_names,
234 host_user_id,
235 is_last,
236 } => Self::render_participant_project(
237 *project_id,
238 worktree_root_names,
239 *host_user_id,
240 Some(*project_id) == current_project_id,
241 *is_last,
242 is_selected,
243 &theme.collab_panel,
244 cx,
245 ),
246 ListEntry::ParticipantScreen { peer_id, is_last } => {
247 Self::render_participant_screen(
248 *peer_id,
249 *is_last,
250 is_selected,
251 &theme.collab_panel,
252 cx,
253 )
254 }
255 ListEntry::Channel(channel) => {
256 Self::render_channel(&*channel, &theme.collab_panel, is_selected, cx)
257 }
258 ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
259 channel.clone(),
260 this.channel_store.clone(),
261 &theme.collab_panel,
262 is_selected,
263 cx,
264 ),
265 ListEntry::IncomingRequest(user) => Self::render_contact_request(
266 user.clone(),
267 this.user_store.clone(),
268 &theme.collab_panel,
269 true,
270 is_selected,
271 cx,
272 ),
273 ListEntry::OutgoingRequest(user) => Self::render_contact_request(
274 user.clone(),
275 this.user_store.clone(),
276 &theme.collab_panel,
277 false,
278 is_selected,
279 cx,
280 ),
281 ListEntry::Contact { contact, calling } => Self::render_contact(
282 contact,
283 *calling,
284 &this.project,
285 &theme.collab_panel,
286 is_selected,
287 cx,
288 ),
289 ListEntry::ChannelEditor { depth } => {
290 this.render_channel_editor(&theme.collab_panel, *depth, cx)
291 }
292 }
293 });
294
295 let mut this = Self {
296 width: None,
297 has_focus: false,
298 fs: workspace.app_state().fs.clone(),
299 pending_serialization: Task::ready(None),
300 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
301 channel_name_editor,
302 filter_editor,
303 entries: Vec::default(),
304 channel_editing_state: None,
305 selection: None,
306 user_store: workspace.user_store().clone(),
307 channel_store: workspace.app_state().channel_store.clone(),
308 project: workspace.project().clone(),
309 subscriptions: Vec::default(),
310 match_candidates: Vec::default(),
311 collapsed_sections: Vec::default(),
312 workspace: workspace.weak_handle(),
313 list_state,
314 };
315 this.update_entries(cx);
316
317 // Update the dock position when the setting changes.
318 let mut old_dock_position = this.position(cx);
319 this.subscriptions
320 .push(
321 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
322 let new_dock_position = this.position(cx);
323 if new_dock_position != old_dock_position {
324 old_dock_position = new_dock_position;
325 cx.emit(Event::DockPositionChanged);
326 }
327 }),
328 );
329
330 let active_call = ActiveCall::global(cx);
331 this.subscriptions
332 .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx)));
333 this.subscriptions
334 .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx)));
335 this.subscriptions
336 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
337
338 this
339 })
340 }
341
342 pub fn load(
343 workspace: WeakViewHandle<Workspace>,
344 cx: AsyncAppContext,
345 ) -> Task<Result<ViewHandle<Self>>> {
346 cx.spawn(|mut cx| async move {
347 let serialized_panel = if let Some(panel) = cx
348 .background()
349 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
350 .await
351 .log_err()
352 .flatten()
353 {
354 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
355 } else {
356 None
357 };
358
359 workspace.update(&mut cx, |workspace, cx| {
360 let panel = CollabPanel::new(workspace, cx);
361 if let Some(serialized_panel) = serialized_panel {
362 panel.update(cx, |panel, cx| {
363 panel.width = serialized_panel.width;
364 cx.notify();
365 });
366 }
367 panel
368 })
369 })
370 }
371
372 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
373 let width = self.width;
374 self.pending_serialization = cx.background().spawn(
375 async move {
376 KEY_VALUE_STORE
377 .write_kvp(
378 CHANNELS_PANEL_KEY.into(),
379 serde_json::to_string(&SerializedChannelsPanel { width })?,
380 )
381 .await?;
382 anyhow::Ok(())
383 }
384 .log_err(),
385 );
386 }
387
388 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
389 let channel_store = self.channel_store.read(cx);
390 let user_store = self.user_store.read(cx);
391 let query = self.filter_editor.read(cx).text(cx);
392 let executor = cx.background().clone();
393
394 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
395 let old_entries = mem::take(&mut self.entries);
396
397 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
398 let room = room.read(cx);
399 let mut participant_entries = Vec::new();
400
401 // Populate the active user.
402 if let Some(user) = user_store.current_user() {
403 self.match_candidates.clear();
404 self.match_candidates.push(StringMatchCandidate {
405 id: 0,
406 string: user.github_login.clone(),
407 char_bag: user.github_login.chars().collect(),
408 });
409 let matches = executor.block(match_strings(
410 &self.match_candidates,
411 &query,
412 true,
413 usize::MAX,
414 &Default::default(),
415 executor.clone(),
416 ));
417 if !matches.is_empty() {
418 let user_id = user.id;
419 participant_entries.push(ListEntry::CallParticipant {
420 user,
421 is_pending: false,
422 });
423 let mut projects = room.local_participant().projects.iter().peekable();
424 while let Some(project) = projects.next() {
425 participant_entries.push(ListEntry::ParticipantProject {
426 project_id: project.id,
427 worktree_root_names: project.worktree_root_names.clone(),
428 host_user_id: user_id,
429 is_last: projects.peek().is_none(),
430 });
431 }
432 }
433 }
434
435 // Populate remote participants.
436 self.match_candidates.clear();
437 self.match_candidates
438 .extend(room.remote_participants().iter().map(|(_, participant)| {
439 StringMatchCandidate {
440 id: participant.user.id as usize,
441 string: participant.user.github_login.clone(),
442 char_bag: participant.user.github_login.chars().collect(),
443 }
444 }));
445 let matches = executor.block(match_strings(
446 &self.match_candidates,
447 &query,
448 true,
449 usize::MAX,
450 &Default::default(),
451 executor.clone(),
452 ));
453 for mat in matches {
454 let user_id = mat.candidate_id as u64;
455 let participant = &room.remote_participants()[&user_id];
456 participant_entries.push(ListEntry::CallParticipant {
457 user: participant.user.clone(),
458 is_pending: false,
459 });
460 let mut projects = participant.projects.iter().peekable();
461 while let Some(project) = projects.next() {
462 participant_entries.push(ListEntry::ParticipantProject {
463 project_id: project.id,
464 worktree_root_names: project.worktree_root_names.clone(),
465 host_user_id: participant.user.id,
466 is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
467 });
468 }
469 if !participant.video_tracks.is_empty() {
470 participant_entries.push(ListEntry::ParticipantScreen {
471 peer_id: participant.peer_id,
472 is_last: true,
473 });
474 }
475 }
476
477 // Populate pending participants.
478 self.match_candidates.clear();
479 self.match_candidates
480 .extend(
481 room.pending_participants()
482 .iter()
483 .enumerate()
484 .map(|(id, participant)| StringMatchCandidate {
485 id,
486 string: participant.github_login.clone(),
487 char_bag: participant.github_login.chars().collect(),
488 }),
489 );
490 let matches = executor.block(match_strings(
491 &self.match_candidates,
492 &query,
493 true,
494 usize::MAX,
495 &Default::default(),
496 executor.clone(),
497 ));
498 participant_entries.extend(matches.iter().map(|mat| ListEntry::CallParticipant {
499 user: room.pending_participants()[mat.candidate_id].clone(),
500 is_pending: true,
501 }));
502
503 if !participant_entries.is_empty() {
504 self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
505 if !self.collapsed_sections.contains(&Section::ActiveCall) {
506 self.entries.extend(participant_entries);
507 }
508 }
509 }
510
511 self.entries.push(ListEntry::Header(Section::Channels, 0));
512
513 let channels = channel_store.channels();
514 if !(channels.is_empty() && self.channel_editing_state.is_none()) {
515 self.match_candidates.clear();
516 self.match_candidates
517 .extend(
518 channels
519 .iter()
520 .enumerate()
521 .map(|(ix, channel)| StringMatchCandidate {
522 id: ix,
523 string: channel.name.clone(),
524 char_bag: channel.name.chars().collect(),
525 }),
526 );
527 let matches = executor.block(match_strings(
528 &self.match_candidates,
529 &query,
530 true,
531 usize::MAX,
532 &Default::default(),
533 executor.clone(),
534 ));
535 if let Some(state) = &self.channel_editing_state {
536 if state.parent_id.is_none() {
537 self.entries.push(ListEntry::ChannelEditor { depth: 0 });
538 }
539 }
540 for mat in matches {
541 let channel = &channels[mat.candidate_id];
542 self.entries.push(ListEntry::Channel(channel.clone()));
543 if let Some(state) = &self.channel_editing_state {
544 if state.parent_id == Some(channel.id) {
545 self.entries.push(ListEntry::ChannelEditor {
546 depth: channel.depth + 1,
547 });
548 }
549 }
550 }
551 }
552
553 self.entries.push(ListEntry::Header(Section::Contacts, 0));
554
555 let mut request_entries = Vec::new();
556 let channel_invites = channel_store.channel_invitations();
557 if !channel_invites.is_empty() {
558 self.match_candidates.clear();
559 self.match_candidates
560 .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
561 StringMatchCandidate {
562 id: ix,
563 string: channel.name.clone(),
564 char_bag: channel.name.chars().collect(),
565 }
566 }));
567 let matches = executor.block(match_strings(
568 &self.match_candidates,
569 &query,
570 true,
571 usize::MAX,
572 &Default::default(),
573 executor.clone(),
574 ));
575 request_entries.extend(
576 matches
577 .iter()
578 .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())),
579 );
580 }
581
582 let incoming = user_store.incoming_contact_requests();
583 if !incoming.is_empty() {
584 self.match_candidates.clear();
585 self.match_candidates
586 .extend(
587 incoming
588 .iter()
589 .enumerate()
590 .map(|(ix, user)| StringMatchCandidate {
591 id: ix,
592 string: user.github_login.clone(),
593 char_bag: user.github_login.chars().collect(),
594 }),
595 );
596 let matches = executor.block(match_strings(
597 &self.match_candidates,
598 &query,
599 true,
600 usize::MAX,
601 &Default::default(),
602 executor.clone(),
603 ));
604 request_entries.extend(
605 matches
606 .iter()
607 .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
608 );
609 }
610
611 let outgoing = user_store.outgoing_contact_requests();
612 if !outgoing.is_empty() {
613 self.match_candidates.clear();
614 self.match_candidates
615 .extend(
616 outgoing
617 .iter()
618 .enumerate()
619 .map(|(ix, user)| StringMatchCandidate {
620 id: ix,
621 string: user.github_login.clone(),
622 char_bag: user.github_login.chars().collect(),
623 }),
624 );
625 let matches = executor.block(match_strings(
626 &self.match_candidates,
627 &query,
628 true,
629 usize::MAX,
630 &Default::default(),
631 executor.clone(),
632 ));
633 request_entries.extend(
634 matches
635 .iter()
636 .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
637 );
638 }
639
640 if !request_entries.is_empty() {
641 self.entries.push(ListEntry::Header(Section::Requests, 1));
642 if !self.collapsed_sections.contains(&Section::Requests) {
643 self.entries.append(&mut request_entries);
644 }
645 }
646
647 let contacts = user_store.contacts();
648 if !contacts.is_empty() {
649 self.match_candidates.clear();
650 self.match_candidates
651 .extend(
652 contacts
653 .iter()
654 .enumerate()
655 .map(|(ix, contact)| StringMatchCandidate {
656 id: ix,
657 string: contact.user.github_login.clone(),
658 char_bag: contact.user.github_login.chars().collect(),
659 }),
660 );
661
662 let matches = executor.block(match_strings(
663 &self.match_candidates,
664 &query,
665 true,
666 usize::MAX,
667 &Default::default(),
668 executor.clone(),
669 ));
670
671 let (mut online_contacts, offline_contacts) = matches
672 .iter()
673 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
674 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
675 let room = room.read(cx);
676 online_contacts.retain(|contact| {
677 let contact = &contacts[contact.candidate_id];
678 !room.contains_participant(contact.user.id)
679 });
680 }
681
682 for (matches, section) in [
683 (online_contacts, Section::Online),
684 (offline_contacts, Section::Offline),
685 ] {
686 if !matches.is_empty() {
687 self.entries.push(ListEntry::Header(section, 1));
688 if !self.collapsed_sections.contains(§ion) {
689 let active_call = &ActiveCall::global(cx).read(cx);
690 for mat in matches {
691 let contact = &contacts[mat.candidate_id];
692 self.entries.push(ListEntry::Contact {
693 contact: contact.clone(),
694 calling: active_call.pending_invites().contains(&contact.user.id),
695 });
696 }
697 }
698 }
699 }
700 }
701
702 if let Some(prev_selected_entry) = prev_selected_entry {
703 self.selection.take();
704 for (ix, entry) in self.entries.iter().enumerate() {
705 if *entry == prev_selected_entry {
706 self.selection = Some(ix);
707 break;
708 }
709 }
710 }
711
712 let old_scroll_top = self.list_state.logical_scroll_top();
713 self.list_state.reset(self.entries.len());
714
715 // Attempt to maintain the same scroll position.
716 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
717 let new_scroll_top = self
718 .entries
719 .iter()
720 .position(|entry| entry == old_top_entry)
721 .map(|item_ix| ListOffset {
722 item_ix,
723 offset_in_item: old_scroll_top.offset_in_item,
724 })
725 .or_else(|| {
726 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
727 let item_ix = self
728 .entries
729 .iter()
730 .position(|entry| entry == entry_after_old_top)?;
731 Some(ListOffset {
732 item_ix,
733 offset_in_item: 0.,
734 })
735 })
736 .or_else(|| {
737 let entry_before_old_top =
738 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
739 let item_ix = self
740 .entries
741 .iter()
742 .position(|entry| entry == entry_before_old_top)?;
743 Some(ListOffset {
744 item_ix,
745 offset_in_item: 0.,
746 })
747 });
748
749 self.list_state
750 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
751 }
752
753 cx.notify();
754 }
755
756 fn render_call_participant(
757 user: &User,
758 is_pending: bool,
759 is_selected: bool,
760 theme: &theme::CollabPanel,
761 ) -> AnyElement<Self> {
762 Flex::row()
763 .with_children(user.avatar.clone().map(|avatar| {
764 Image::from_data(avatar)
765 .with_style(theme.contact_avatar)
766 .aligned()
767 .left()
768 }))
769 .with_child(
770 Label::new(
771 user.github_login.clone(),
772 theme.contact_username.text.clone(),
773 )
774 .contained()
775 .with_style(theme.contact_username.container)
776 .aligned()
777 .left()
778 .flex(1., true),
779 )
780 .with_children(if is_pending {
781 Some(
782 Label::new("Calling", theme.calling_indicator.text.clone())
783 .contained()
784 .with_style(theme.calling_indicator.container)
785 .aligned(),
786 )
787 } else {
788 None
789 })
790 .constrained()
791 .with_height(theme.row_height)
792 .contained()
793 .with_style(
794 *theme
795 .contact_row
796 .in_state(is_selected)
797 .style_for(&mut Default::default()),
798 )
799 .into_any()
800 }
801
802 fn render_participant_project(
803 project_id: u64,
804 worktree_root_names: &[String],
805 host_user_id: u64,
806 is_current: bool,
807 is_last: bool,
808 is_selected: bool,
809 theme: &theme::CollabPanel,
810 cx: &mut ViewContext<Self>,
811 ) -> AnyElement<Self> {
812 enum JoinProject {}
813
814 let font_cache = cx.font_cache();
815 let host_avatar_height = theme
816 .contact_avatar
817 .width
818 .or(theme.contact_avatar.height)
819 .unwrap_or(0.);
820 let row = &theme.project_row.inactive_state().default;
821 let tree_branch = theme.tree_branch;
822 let line_height = row.name.text.line_height(font_cache);
823 let cap_height = row.name.text.cap_height(font_cache);
824 let baseline_offset =
825 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
826 let project_name = if worktree_root_names.is_empty() {
827 "untitled".to_string()
828 } else {
829 worktree_root_names.join(", ")
830 };
831
832 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
833 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
834 let row = theme
835 .project_row
836 .in_state(is_selected)
837 .style_for(mouse_state);
838
839 Flex::row()
840 .with_child(
841 Stack::new()
842 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
843 let start_x =
844 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
845 let end_x = bounds.max_x();
846 let start_y = bounds.min_y();
847 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
848
849 scene.push_quad(gpui::Quad {
850 bounds: RectF::from_points(
851 vec2f(start_x, start_y),
852 vec2f(
853 start_x + tree_branch.width,
854 if is_last { end_y } else { bounds.max_y() },
855 ),
856 ),
857 background: Some(tree_branch.color),
858 border: gpui::Border::default(),
859 corner_radius: 0.,
860 });
861 scene.push_quad(gpui::Quad {
862 bounds: RectF::from_points(
863 vec2f(start_x, end_y),
864 vec2f(end_x, end_y + tree_branch.width),
865 ),
866 background: Some(tree_branch.color),
867 border: gpui::Border::default(),
868 corner_radius: 0.,
869 });
870 }))
871 .constrained()
872 .with_width(host_avatar_height),
873 )
874 .with_child(
875 Label::new(project_name, row.name.text.clone())
876 .aligned()
877 .left()
878 .contained()
879 .with_style(row.name.container)
880 .flex(1., false),
881 )
882 .constrained()
883 .with_height(theme.row_height)
884 .contained()
885 .with_style(row.container)
886 })
887 .with_cursor_style(if !is_current {
888 CursorStyle::PointingHand
889 } else {
890 CursorStyle::Arrow
891 })
892 .on_click(MouseButton::Left, move |_, this, cx| {
893 if !is_current {
894 if let Some(workspace) = this.workspace.upgrade(cx) {
895 let app_state = workspace.read(cx).app_state().clone();
896 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
897 .detach_and_log_err(cx);
898 }
899 }
900 })
901 .into_any()
902 }
903
904 fn render_participant_screen(
905 peer_id: PeerId,
906 is_last: bool,
907 is_selected: bool,
908 theme: &theme::CollabPanel,
909 cx: &mut ViewContext<Self>,
910 ) -> AnyElement<Self> {
911 enum OpenSharedScreen {}
912
913 let font_cache = cx.font_cache();
914 let host_avatar_height = theme
915 .contact_avatar
916 .width
917 .or(theme.contact_avatar.height)
918 .unwrap_or(0.);
919 let row = &theme.project_row.inactive_state().default;
920 let tree_branch = theme.tree_branch;
921 let line_height = row.name.text.line_height(font_cache);
922 let cap_height = row.name.text.cap_height(font_cache);
923 let baseline_offset =
924 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
925
926 MouseEventHandler::<OpenSharedScreen, Self>::new(
927 peer_id.as_u64() as usize,
928 cx,
929 |mouse_state, _| {
930 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
931 let row = theme
932 .project_row
933 .in_state(is_selected)
934 .style_for(mouse_state);
935
936 Flex::row()
937 .with_child(
938 Stack::new()
939 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
940 let start_x = bounds.min_x() + (bounds.width() / 2.)
941 - (tree_branch.width / 2.);
942 let end_x = bounds.max_x();
943 let start_y = bounds.min_y();
944 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
945
946 scene.push_quad(gpui::Quad {
947 bounds: RectF::from_points(
948 vec2f(start_x, start_y),
949 vec2f(
950 start_x + tree_branch.width,
951 if is_last { end_y } else { bounds.max_y() },
952 ),
953 ),
954 background: Some(tree_branch.color),
955 border: gpui::Border::default(),
956 corner_radius: 0.,
957 });
958 scene.push_quad(gpui::Quad {
959 bounds: RectF::from_points(
960 vec2f(start_x, end_y),
961 vec2f(end_x, end_y + tree_branch.width),
962 ),
963 background: Some(tree_branch.color),
964 border: gpui::Border::default(),
965 corner_radius: 0.,
966 });
967 }))
968 .constrained()
969 .with_width(host_avatar_height),
970 )
971 .with_child(
972 Svg::new("icons/disable_screen_sharing_12.svg")
973 .with_color(row.icon.color)
974 .constrained()
975 .with_width(row.icon.width)
976 .aligned()
977 .left()
978 .contained()
979 .with_style(row.icon.container),
980 )
981 .with_child(
982 Label::new("Screen", row.name.text.clone())
983 .aligned()
984 .left()
985 .contained()
986 .with_style(row.name.container)
987 .flex(1., false),
988 )
989 .constrained()
990 .with_height(theme.row_height)
991 .contained()
992 .with_style(row.container)
993 },
994 )
995 .with_cursor_style(CursorStyle::PointingHand)
996 .on_click(MouseButton::Left, move |_, this, cx| {
997 if let Some(workspace) = this.workspace.upgrade(cx) {
998 workspace.update(cx, |workspace, cx| {
999 workspace.open_shared_screen(peer_id, cx)
1000 });
1001 }
1002 })
1003 .into_any()
1004 }
1005
1006 fn take_editing_state(
1007 &mut self,
1008 cx: &mut ViewContext<Self>,
1009 ) -> Option<(ChannelEditingState, String)> {
1010 let result = self
1011 .channel_editing_state
1012 .take()
1013 .map(|state| (state, self.channel_name_editor.read(cx).text(cx)));
1014
1015 self.channel_name_editor
1016 .update(cx, |editor, cx| editor.set_text("", cx));
1017
1018 result
1019 }
1020
1021 fn render_header(
1022 &self,
1023 section: Section,
1024 theme: &theme::Theme,
1025 depth: usize,
1026 is_selected: bool,
1027 is_collapsed: bool,
1028 cx: &mut ViewContext<Self>,
1029 ) -> AnyElement<Self> {
1030 enum Header {}
1031 enum LeaveCallContactList {}
1032 enum AddChannel {}
1033
1034 let tooltip_style = &theme.tooltip;
1035 let text = match section {
1036 Section::ActiveCall => "Current Call",
1037 Section::Requests => "Requests",
1038 Section::Contacts => "Contacts",
1039 Section::Channels => "Channels",
1040 Section::Online => "Online",
1041 Section::Offline => "Offline",
1042 };
1043
1044 enum AddContact {}
1045 let button = match section {
1046 Section::ActiveCall => Some(
1047 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
1048 render_icon_button(
1049 &theme.collab_panel.leave_call_button,
1050 "icons/radix/exit.svg",
1051 )
1052 })
1053 .with_cursor_style(CursorStyle::PointingHand)
1054 .on_click(MouseButton::Left, |_, _, cx| {
1055 ActiveCall::global(cx)
1056 .update(cx, |call, cx| call.hang_up(cx))
1057 .detach_and_log_err(cx);
1058 })
1059 .with_tooltip::<AddContact>(
1060 0,
1061 "Leave call".into(),
1062 None,
1063 tooltip_style.clone(),
1064 cx,
1065 ),
1066 ),
1067 Section::Contacts => Some(
1068 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |_, _| {
1069 render_icon_button(
1070 &theme.collab_panel.add_contact_button,
1071 "icons/user_plus_16.svg",
1072 )
1073 })
1074 .with_cursor_style(CursorStyle::PointingHand)
1075 .on_click(MouseButton::Left, |_, this, cx| {
1076 this.toggle_contact_finder(cx);
1077 })
1078 .with_tooltip::<LeaveCallContactList>(
1079 0,
1080 "Search for new contact".into(),
1081 None,
1082 tooltip_style.clone(),
1083 cx,
1084 ),
1085 ),
1086 Section::Channels => Some(
1087 MouseEventHandler::<AddChannel, Self>::new(0, cx, |_, _| {
1088 render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg")
1089 })
1090 .with_cursor_style(CursorStyle::PointingHand)
1091 .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
1092 .with_tooltip::<AddChannel>(
1093 0,
1094 "Add or join a channel".into(),
1095 None,
1096 tooltip_style.clone(),
1097 cx,
1098 ),
1099 ),
1100 _ => None,
1101 };
1102
1103 let can_collapse = depth > 0;
1104 let icon_size = (&theme.collab_panel).section_icon_size;
1105 MouseEventHandler::<Header, Self>::new(section as usize, cx, |state, _| {
1106 let header_style = if can_collapse {
1107 theme
1108 .collab_panel
1109 .subheader_row
1110 .in_state(is_selected)
1111 .style_for(state)
1112 } else {
1113 &theme.collab_panel.header_row
1114 };
1115
1116 Flex::row()
1117 .with_children(if can_collapse {
1118 Some(
1119 Svg::new(if is_collapsed {
1120 "icons/chevron_right_8.svg"
1121 } else {
1122 "icons/chevron_down_8.svg"
1123 })
1124 .with_color(header_style.text.color)
1125 .constrained()
1126 .with_max_width(icon_size)
1127 .with_max_height(icon_size)
1128 .aligned()
1129 .constrained()
1130 .with_width(icon_size)
1131 .contained()
1132 .with_margin_right(
1133 theme.collab_panel.contact_username.container.margin.left,
1134 ),
1135 )
1136 } else {
1137 None
1138 })
1139 .with_child(
1140 Label::new(text, header_style.text.clone())
1141 .aligned()
1142 .left()
1143 .flex(1., true),
1144 )
1145 .with_children(button.map(|button| button.aligned().right()))
1146 .constrained()
1147 .with_height(theme.collab_panel.row_height)
1148 .contained()
1149 .with_style(header_style.container)
1150 })
1151 .with_cursor_style(CursorStyle::PointingHand)
1152 .on_click(MouseButton::Left, move |_, this, cx| {
1153 if can_collapse {
1154 this.toggle_expanded(section, cx);
1155 }
1156 })
1157 .into_any()
1158 }
1159
1160 fn render_contact(
1161 contact: &Contact,
1162 calling: bool,
1163 project: &ModelHandle<Project>,
1164 theme: &theme::CollabPanel,
1165 is_selected: bool,
1166 cx: &mut ViewContext<Self>,
1167 ) -> AnyElement<Self> {
1168 let online = contact.online;
1169 let busy = contact.busy || calling;
1170 let user_id = contact.user.id;
1171 let github_login = contact.user.github_login.clone();
1172 let initial_project = project.clone();
1173 let mut event_handler =
1174 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |state, cx| {
1175 Flex::row()
1176 .with_children(contact.user.avatar.clone().map(|avatar| {
1177 let status_badge = if contact.online {
1178 Some(
1179 Empty::new()
1180 .collapsed()
1181 .contained()
1182 .with_style(if busy {
1183 theme.contact_status_busy
1184 } else {
1185 theme.contact_status_free
1186 })
1187 .aligned(),
1188 )
1189 } else {
1190 None
1191 };
1192 Stack::new()
1193 .with_child(
1194 Image::from_data(avatar)
1195 .with_style(theme.contact_avatar)
1196 .aligned()
1197 .left(),
1198 )
1199 .with_children(status_badge)
1200 }))
1201 .with_child(
1202 Label::new(
1203 contact.user.github_login.clone(),
1204 theme.contact_username.text.clone(),
1205 )
1206 .contained()
1207 .with_style(theme.contact_username.container)
1208 .aligned()
1209 .left()
1210 .flex(1., true),
1211 )
1212 .with_child(
1213 MouseEventHandler::<Cancel, Self>::new(
1214 contact.user.id as usize,
1215 cx,
1216 |mouse_state, _| {
1217 let button_style = theme.contact_button.style_for(mouse_state);
1218 render_icon_button(button_style, "icons/x_mark_8.svg")
1219 .aligned()
1220 .flex_float()
1221 },
1222 )
1223 .with_padding(Padding::uniform(2.))
1224 .with_cursor_style(CursorStyle::PointingHand)
1225 .on_click(MouseButton::Left, move |_, this, cx| {
1226 this.remove_contact(user_id, &github_login, cx);
1227 })
1228 .flex_float(),
1229 )
1230 .with_children(if calling {
1231 Some(
1232 Label::new("Calling", theme.calling_indicator.text.clone())
1233 .contained()
1234 .with_style(theme.calling_indicator.container)
1235 .aligned(),
1236 )
1237 } else {
1238 None
1239 })
1240 .constrained()
1241 .with_height(theme.row_height)
1242 .contained()
1243 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1244 })
1245 .on_click(MouseButton::Left, move |_, this, cx| {
1246 if online && !busy {
1247 this.call(user_id, Some(initial_project.clone()), cx);
1248 }
1249 });
1250
1251 if online {
1252 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1253 }
1254
1255 event_handler.into_any()
1256 }
1257
1258 fn render_channel_editor(
1259 &self,
1260 theme: &theme::CollabPanel,
1261 depth: usize,
1262 cx: &AppContext,
1263 ) -> AnyElement<Self> {
1264 ChildView::new(&self.channel_name_editor, cx).into_any()
1265 }
1266
1267 fn render_channel(
1268 channel: &Channel,
1269 theme: &theme::CollabPanel,
1270 is_selected: bool,
1271 cx: &mut ViewContext<Self>,
1272 ) -> AnyElement<Self> {
1273 let channel_id = channel.id;
1274 MouseEventHandler::<Channel, Self>::new(channel.id as usize, cx, |state, _cx| {
1275 Flex::row()
1276 .with_child({
1277 Svg::new("icons/file_icons/hash.svg")
1278 // .with_style(theme.contact_avatar)
1279 .aligned()
1280 .left()
1281 })
1282 .with_child(
1283 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1284 .contained()
1285 .with_style(theme.contact_username.container)
1286 .aligned()
1287 .left()
1288 .flex(1., true),
1289 )
1290 .constrained()
1291 .with_height(theme.row_height)
1292 .contained()
1293 .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
1294 .with_margin_left(20. * channel.depth as f32)
1295 })
1296 .on_click(MouseButton::Left, move |_, this, cx| {
1297 this.join_channel(channel_id, cx);
1298 })
1299 .on_click(MouseButton::Right, move |e, this, cx| {
1300 this.deploy_channel_context_menu(e.position, channel_id, cx);
1301 })
1302 .into_any()
1303 }
1304
1305 fn render_channel_invite(
1306 channel: Arc<Channel>,
1307 channel_store: ModelHandle<ChannelStore>,
1308 theme: &theme::CollabPanel,
1309 is_selected: bool,
1310 cx: &mut ViewContext<Self>,
1311 ) -> AnyElement<Self> {
1312 enum Decline {}
1313 enum Accept {}
1314
1315 let channel_id = channel.id;
1316 let is_invite_pending = channel_store.read(cx).is_channel_invite_pending(&channel);
1317 let button_spacing = theme.contact_button_spacing;
1318
1319 Flex::row()
1320 .with_child({
1321 Svg::new("icons/file_icons/hash.svg")
1322 // .with_style(theme.contact_avatar)
1323 .aligned()
1324 .left()
1325 })
1326 .with_child(
1327 Label::new(channel.name.clone(), theme.contact_username.text.clone())
1328 .contained()
1329 .with_style(theme.contact_username.container)
1330 .aligned()
1331 .left()
1332 .flex(1., true),
1333 )
1334 .with_child(
1335 MouseEventHandler::<Decline, Self>::new(
1336 channel.id as usize,
1337 cx,
1338 |mouse_state, _| {
1339 let button_style = if is_invite_pending {
1340 &theme.disabled_button
1341 } else {
1342 theme.contact_button.style_for(mouse_state)
1343 };
1344 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1345 },
1346 )
1347 .with_cursor_style(CursorStyle::PointingHand)
1348 .on_click(MouseButton::Left, move |_, this, cx| {
1349 this.respond_to_channel_invite(channel_id, false, cx);
1350 })
1351 .contained()
1352 .with_margin_right(button_spacing),
1353 )
1354 .with_child(
1355 MouseEventHandler::<Accept, Self>::new(
1356 channel.id as usize,
1357 cx,
1358 |mouse_state, _| {
1359 let button_style = if is_invite_pending {
1360 &theme.disabled_button
1361 } else {
1362 theme.contact_button.style_for(mouse_state)
1363 };
1364 render_icon_button(button_style, "icons/check_8.svg")
1365 .aligned()
1366 .flex_float()
1367 },
1368 )
1369 .with_cursor_style(CursorStyle::PointingHand)
1370 .on_click(MouseButton::Left, move |_, this, cx| {
1371 this.respond_to_channel_invite(channel_id, true, cx);
1372 }),
1373 )
1374 .constrained()
1375 .with_height(theme.row_height)
1376 .contained()
1377 .with_style(
1378 *theme
1379 .contact_row
1380 .in_state(is_selected)
1381 .style_for(&mut Default::default()),
1382 )
1383 .into_any()
1384 }
1385
1386 fn render_contact_request(
1387 user: Arc<User>,
1388 user_store: ModelHandle<UserStore>,
1389 theme: &theme::CollabPanel,
1390 is_incoming: bool,
1391 is_selected: bool,
1392 cx: &mut ViewContext<Self>,
1393 ) -> AnyElement<Self> {
1394 enum Decline {}
1395 enum Accept {}
1396 enum Cancel {}
1397
1398 let mut row = Flex::row()
1399 .with_children(user.avatar.clone().map(|avatar| {
1400 Image::from_data(avatar)
1401 .with_style(theme.contact_avatar)
1402 .aligned()
1403 .left()
1404 }))
1405 .with_child(
1406 Label::new(
1407 user.github_login.clone(),
1408 theme.contact_username.text.clone(),
1409 )
1410 .contained()
1411 .with_style(theme.contact_username.container)
1412 .aligned()
1413 .left()
1414 .flex(1., true),
1415 );
1416
1417 let user_id = user.id;
1418 let github_login = user.github_login.clone();
1419 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1420 let button_spacing = theme.contact_button_spacing;
1421
1422 if is_incoming {
1423 row.add_child(
1424 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1425 let button_style = if is_contact_request_pending {
1426 &theme.disabled_button
1427 } else {
1428 theme.contact_button.style_for(mouse_state)
1429 };
1430 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1431 })
1432 .with_cursor_style(CursorStyle::PointingHand)
1433 .on_click(MouseButton::Left, move |_, this, cx| {
1434 this.respond_to_contact_request(user_id, false, cx);
1435 })
1436 .contained()
1437 .with_margin_right(button_spacing),
1438 );
1439
1440 row.add_child(
1441 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1442 let button_style = if is_contact_request_pending {
1443 &theme.disabled_button
1444 } else {
1445 theme.contact_button.style_for(mouse_state)
1446 };
1447 render_icon_button(button_style, "icons/check_8.svg")
1448 .aligned()
1449 .flex_float()
1450 })
1451 .with_cursor_style(CursorStyle::PointingHand)
1452 .on_click(MouseButton::Left, move |_, this, cx| {
1453 this.respond_to_contact_request(user_id, true, cx);
1454 }),
1455 );
1456 } else {
1457 row.add_child(
1458 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1459 let button_style = if is_contact_request_pending {
1460 &theme.disabled_button
1461 } else {
1462 theme.contact_button.style_for(mouse_state)
1463 };
1464 render_icon_button(button_style, "icons/x_mark_8.svg")
1465 .aligned()
1466 .flex_float()
1467 })
1468 .with_padding(Padding::uniform(2.))
1469 .with_cursor_style(CursorStyle::PointingHand)
1470 .on_click(MouseButton::Left, move |_, this, cx| {
1471 this.remove_contact(user_id, &github_login, cx);
1472 })
1473 .flex_float(),
1474 );
1475 }
1476
1477 row.constrained()
1478 .with_height(theme.row_height)
1479 .contained()
1480 .with_style(
1481 *theme
1482 .contact_row
1483 .in_state(is_selected)
1484 .style_for(&mut Default::default()),
1485 )
1486 .into_any()
1487 }
1488
1489 fn deploy_channel_context_menu(
1490 &mut self,
1491 position: Vector2F,
1492 channel_id: u64,
1493 cx: &mut ViewContext<Self>,
1494 ) {
1495 self.context_menu.update(cx, |context_menu, cx| {
1496 context_menu.show(
1497 position,
1498 gpui::elements::AnchorCorner::BottomLeft,
1499 vec![
1500 ContextMenuItem::action("New Channel", NewChannel { channel_id }),
1501 ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }),
1502 ],
1503 cx,
1504 );
1505 });
1506 }
1507
1508 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1509 let mut did_clear = self.filter_editor.update(cx, |editor, cx| {
1510 if editor.buffer().read(cx).len(cx) > 0 {
1511 editor.set_text("", cx);
1512 true
1513 } else {
1514 false
1515 }
1516 });
1517
1518 did_clear |= self.take_editing_state(cx).is_some();
1519
1520 if !did_clear {
1521 cx.emit(Event::Dismissed);
1522 }
1523 }
1524
1525 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1526 let mut ix = self.selection.map_or(0, |ix| ix + 1);
1527 while let Some(entry) = self.entries.get(ix) {
1528 if entry.is_selectable() {
1529 self.selection = Some(ix);
1530 break;
1531 }
1532 ix += 1;
1533 }
1534
1535 self.list_state.reset(self.entries.len());
1536 if let Some(ix) = self.selection {
1537 self.list_state.scroll_to(ListOffset {
1538 item_ix: ix,
1539 offset_in_item: 0.,
1540 });
1541 }
1542 cx.notify();
1543 }
1544
1545 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1546 if let Some(mut ix) = self.selection.take() {
1547 while ix > 0 {
1548 ix -= 1;
1549 if let Some(entry) = self.entries.get(ix) {
1550 if entry.is_selectable() {
1551 self.selection = Some(ix);
1552 break;
1553 }
1554 }
1555 }
1556 }
1557
1558 self.list_state.reset(self.entries.len());
1559 if let Some(ix) = self.selection {
1560 self.list_state.scroll_to(ListOffset {
1561 item_ix: ix,
1562 offset_in_item: 0.,
1563 });
1564 }
1565 cx.notify();
1566 }
1567
1568 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1569 if let Some(selection) = self.selection {
1570 if let Some(entry) = self.entries.get(selection) {
1571 match entry {
1572 ListEntry::Header(section, _) => {
1573 self.toggle_expanded(*section, cx);
1574 }
1575 ListEntry::Contact { contact, calling } => {
1576 if contact.online && !contact.busy && !calling {
1577 self.call(contact.user.id, Some(self.project.clone()), cx);
1578 }
1579 }
1580 ListEntry::ParticipantProject {
1581 project_id,
1582 host_user_id,
1583 ..
1584 } => {
1585 if let Some(workspace) = self.workspace.upgrade(cx) {
1586 let app_state = workspace.read(cx).app_state().clone();
1587 workspace::join_remote_project(
1588 *project_id,
1589 *host_user_id,
1590 app_state,
1591 cx,
1592 )
1593 .detach_and_log_err(cx);
1594 }
1595 }
1596 ListEntry::ParticipantScreen { peer_id, .. } => {
1597 if let Some(workspace) = self.workspace.upgrade(cx) {
1598 workspace.update(cx, |workspace, cx| {
1599 workspace.open_shared_screen(*peer_id, cx)
1600 });
1601 }
1602 }
1603 _ => {}
1604 }
1605 }
1606 } else if let Some((editing_state, channel_name)) = self.take_editing_state(cx) {
1607 let create_channel = self.channel_store.update(cx, |channel_store, cx| {
1608 channel_store.create_channel(&channel_name, editing_state.parent_id)
1609 });
1610
1611 cx.foreground()
1612 .spawn(async move {
1613 create_channel.await.ok();
1614 })
1615 .detach();
1616 }
1617 }
1618
1619 fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1620 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1621 self.collapsed_sections.remove(ix);
1622 } else {
1623 self.collapsed_sections.push(section);
1624 }
1625 self.update_entries(cx);
1626 }
1627
1628 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1629 if let Some(workspace) = self.workspace.upgrade(cx) {
1630 workspace.update(cx, |workspace, cx| {
1631 workspace.toggle_modal(cx, |_, cx| {
1632 cx.add_view(|cx| {
1633 let finder = build_contact_finder(self.user_store.clone(), cx);
1634 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1635 finder
1636 })
1637 });
1638 });
1639 }
1640 }
1641
1642 fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
1643 if self.channel_editing_state.is_none() {
1644 self.channel_editing_state = Some(ChannelEditingState { parent_id: None });
1645 self.update_entries(cx);
1646 }
1647
1648 cx.focus(self.channel_name_editor.as_any());
1649 cx.notify();
1650 }
1651
1652 fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
1653 if self.channel_editing_state.is_none() {
1654 self.channel_editing_state = Some(ChannelEditingState {
1655 parent_id: Some(action.channel_id),
1656 });
1657 self.update_entries(cx);
1658 }
1659
1660 cx.focus(self.channel_name_editor.as_any());
1661 cx.notify();
1662 }
1663
1664 fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
1665 let channel_id = action.channel_id;
1666 let channel_store = self.channel_store.clone();
1667 if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
1668 let prompt_message = format!(
1669 "Are you sure you want to remove the channel \"{}\"?",
1670 channel.name
1671 );
1672 let mut answer =
1673 cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1674 let window_id = cx.window_id();
1675 cx.spawn(|_, mut cx| async move {
1676 if answer.next().await == Some(0) {
1677 if let Err(e) = channel_store
1678 .update(&mut cx, |channels, cx| channels.remove_channel(channel_id))
1679 .await
1680 {
1681 cx.prompt(
1682 window_id,
1683 PromptLevel::Info,
1684 &format!("Failed to remove channel: {}", e),
1685 &["Ok"],
1686 );
1687 }
1688 }
1689 })
1690 .detach();
1691 }
1692 }
1693
1694 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1695 let user_store = self.user_store.clone();
1696 let prompt_message = format!(
1697 "Are you sure you want to remove \"{}\" from your contacts?",
1698 github_login
1699 );
1700 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1701 let window_id = cx.window_id();
1702 cx.spawn(|_, mut cx| async move {
1703 if answer.next().await == Some(0) {
1704 if let Err(e) = user_store
1705 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
1706 .await
1707 {
1708 cx.prompt(
1709 window_id,
1710 PromptLevel::Info,
1711 &format!("Failed to remove contact: {}", e),
1712 &["Ok"],
1713 );
1714 }
1715 }
1716 })
1717 .detach();
1718 }
1719
1720 fn respond_to_contact_request(
1721 &mut self,
1722 user_id: u64,
1723 accept: bool,
1724 cx: &mut ViewContext<Self>,
1725 ) {
1726 self.user_store
1727 .update(cx, |store, cx| {
1728 store.respond_to_contact_request(user_id, accept, cx)
1729 })
1730 .detach();
1731 }
1732
1733 fn respond_to_channel_invite(
1734 &mut self,
1735 channel_id: u64,
1736 accept: bool,
1737 cx: &mut ViewContext<Self>,
1738 ) {
1739 let respond = self.channel_store.update(cx, |store, _| {
1740 store.respond_to_channel_invite(channel_id, accept)
1741 });
1742 cx.foreground().spawn(respond).detach();
1743 }
1744
1745 fn call(
1746 &mut self,
1747 recipient_user_id: u64,
1748 initial_project: Option<ModelHandle<Project>>,
1749 cx: &mut ViewContext<Self>,
1750 ) {
1751 ActiveCall::global(cx)
1752 .update(cx, |call, cx| {
1753 call.invite(recipient_user_id, initial_project, cx)
1754 })
1755 .detach_and_log_err(cx);
1756 }
1757
1758 fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
1759 ActiveCall::global(cx)
1760 .update(cx, |call, cx| call.join_channel(channel, cx))
1761 .detach_and_log_err(cx);
1762 }
1763}
1764
1765impl View for CollabPanel {
1766 fn ui_name() -> &'static str {
1767 "CollabPanel"
1768 }
1769
1770 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1771 if !self.has_focus {
1772 self.has_focus = true;
1773 cx.emit(Event::Focus);
1774 }
1775 }
1776
1777 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1778 self.has_focus = false;
1779 }
1780
1781 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
1782 let theme = &theme::current(cx).collab_panel;
1783
1784 enum PanelFocus {}
1785 MouseEventHandler::<PanelFocus, _>::new(0, cx, |_, cx| {
1786 Stack::new()
1787 .with_child(
1788 Flex::column()
1789 .with_child(
1790 Flex::row()
1791 .with_child(
1792 ChildView::new(&self.filter_editor, cx)
1793 .contained()
1794 .with_style(theme.user_query_editor.container)
1795 .flex(1.0, true),
1796 )
1797 .constrained()
1798 .with_width(self.size(cx)),
1799 )
1800 .with_child(
1801 List::new(self.list_state.clone())
1802 .constrained()
1803 .with_width(self.size(cx))
1804 .flex(1., true)
1805 .into_any(),
1806 )
1807 .contained()
1808 .with_style(theme.container)
1809 .constrained()
1810 .with_width(self.size(cx))
1811 .into_any(),
1812 )
1813 .with_child(ChildView::new(&self.context_menu, cx))
1814 .into_any()
1815 })
1816 .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
1817 .into_any_named("channels panel")
1818 }
1819}
1820
1821impl Panel for CollabPanel {
1822 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
1823 match settings::get::<ChannelsPanelSettings>(cx).dock {
1824 ChannelsPanelDockPosition::Left => DockPosition::Left,
1825 ChannelsPanelDockPosition::Right => DockPosition::Right,
1826 }
1827 }
1828
1829 fn position_is_valid(&self, position: DockPosition) -> bool {
1830 matches!(position, DockPosition::Left | DockPosition::Right)
1831 }
1832
1833 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1834 settings::update_settings_file::<ChannelsPanelSettings>(
1835 self.fs.clone(),
1836 cx,
1837 move |settings| {
1838 let dock = match position {
1839 DockPosition::Left | DockPosition::Bottom => ChannelsPanelDockPosition::Left,
1840 DockPosition::Right => ChannelsPanelDockPosition::Right,
1841 };
1842 settings.dock = Some(dock);
1843 },
1844 );
1845 }
1846
1847 fn size(&self, cx: &gpui::WindowContext) -> f32 {
1848 self.width
1849 .unwrap_or_else(|| settings::get::<ChannelsPanelSettings>(cx).default_width)
1850 }
1851
1852 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1853 self.width = Some(size);
1854 self.serialize(cx);
1855 cx.notify();
1856 }
1857
1858 fn icon_path(&self) -> &'static str {
1859 "icons/radix/person.svg"
1860 }
1861
1862 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
1863 ("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
1864 }
1865
1866 fn should_change_position_on_event(event: &Self::Event) -> bool {
1867 matches!(event, Event::DockPositionChanged)
1868 }
1869
1870 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
1871 self.has_focus
1872 }
1873
1874 fn is_focus_event(event: &Self::Event) -> bool {
1875 matches!(event, Event::Focus)
1876 }
1877}
1878
1879impl ListEntry {
1880 fn is_selectable(&self) -> bool {
1881 if let ListEntry::Header(_, 0) = self {
1882 false
1883 } else {
1884 true
1885 }
1886 }
1887}
1888
1889impl PartialEq for ListEntry {
1890 fn eq(&self, other: &Self) -> bool {
1891 match self {
1892 ListEntry::Header(section_1, depth_1) => {
1893 if let ListEntry::Header(section_2, depth_2) = other {
1894 return section_1 == section_2 && depth_1 == depth_2;
1895 }
1896 }
1897 ListEntry::CallParticipant { user: user_1, .. } => {
1898 if let ListEntry::CallParticipant { user: user_2, .. } = other {
1899 return user_1.id == user_2.id;
1900 }
1901 }
1902 ListEntry::ParticipantProject {
1903 project_id: project_id_1,
1904 ..
1905 } => {
1906 if let ListEntry::ParticipantProject {
1907 project_id: project_id_2,
1908 ..
1909 } = other
1910 {
1911 return project_id_1 == project_id_2;
1912 }
1913 }
1914 ListEntry::ParticipantScreen {
1915 peer_id: peer_id_1, ..
1916 } => {
1917 if let ListEntry::ParticipantScreen {
1918 peer_id: peer_id_2, ..
1919 } = other
1920 {
1921 return peer_id_1 == peer_id_2;
1922 }
1923 }
1924 ListEntry::Channel(channel_1) => {
1925 if let ListEntry::Channel(channel_2) = other {
1926 return channel_1.id == channel_2.id;
1927 }
1928 }
1929 ListEntry::ChannelInvite(channel_1) => {
1930 if let ListEntry::ChannelInvite(channel_2) = other {
1931 return channel_1.id == channel_2.id;
1932 }
1933 }
1934 ListEntry::IncomingRequest(user_1) => {
1935 if let ListEntry::IncomingRequest(user_2) = other {
1936 return user_1.id == user_2.id;
1937 }
1938 }
1939 ListEntry::OutgoingRequest(user_1) => {
1940 if let ListEntry::OutgoingRequest(user_2) = other {
1941 return user_1.id == user_2.id;
1942 }
1943 }
1944 ListEntry::Contact {
1945 contact: contact_1, ..
1946 } => {
1947 if let ListEntry::Contact {
1948 contact: contact_2, ..
1949 } = other
1950 {
1951 return contact_1.user.id == contact_2.user.id;
1952 }
1953 }
1954 ListEntry::ChannelEditor { depth } => {
1955 if let ListEntry::ChannelEditor { depth: other_depth } = other {
1956 return depth == other_depth;
1957 }
1958 }
1959 }
1960 false
1961 }
1962}
1963
1964fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
1965 Svg::new(svg_path)
1966 .with_color(style.color)
1967 .constrained()
1968 .with_width(style.icon_width)
1969 .aligned()
1970 .constrained()
1971 .with_width(style.button_width)
1972 .with_height(style.button_width)
1973 .contained()
1974 .with_style(style.container)
1975}