1mod contact_finder;
2mod panel_settings;
3
4use anyhow::Result;
5use call::ActiveCall;
6use client::{proto::PeerId, Client, Contact, User, UserStore};
7use contact_finder::{build_contact_finder, ContactFinder};
8use context_menu::ContextMenu;
9use db::kvp::KEY_VALUE_STORE;
10use editor::{Cancel, Editor};
11use futures::StreamExt;
12use fuzzy::{match_strings, StringMatchCandidate};
13use gpui::{
14 actions,
15 elements::{
16 Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
17 MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg,
18 },
19 geometry::{rect::RectF, vector::vec2f},
20 platform::{CursorStyle, MouseButton, PromptLevel},
21 serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
22 Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
23};
24use menu::{Confirm, SelectNext, SelectPrev};
25use panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings};
26use project::{Fs, Project};
27use serde_derive::{Deserialize, Serialize};
28use settings::SettingsStore;
29use std::{mem, sync::Arc};
30use theme::IconButton;
31use util::{ResultExt, TryFutureExt};
32use workspace::{
33 dock::{DockPosition, Panel},
34 Workspace,
35};
36
37actions!(collab_panel, [ToggleFocus]);
38
39const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
40
41pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
42 settings::register::<panel_settings::ChannelsPanelSettings>(cx);
43 contact_finder::init(cx);
44
45 cx.add_action(CollabPanel::cancel);
46 cx.add_action(CollabPanel::select_next);
47 cx.add_action(CollabPanel::select_prev);
48 cx.add_action(CollabPanel::confirm);
49}
50
51pub struct CollabPanel {
52 width: Option<f32>,
53 fs: Arc<dyn Fs>,
54 has_focus: bool,
55 pending_serialization: Task<Option<()>>,
56 context_menu: ViewHandle<ContextMenu>,
57 contact_finder: Option<ViewHandle<ContactFinder>>,
58
59 // from contacts list
60 filter_editor: ViewHandle<Editor>,
61 entries: Vec<ContactEntry>,
62 selection: Option<usize>,
63 user_store: ModelHandle<UserStore>,
64 project: ModelHandle<Project>,
65 match_candidates: Vec<StringMatchCandidate>,
66 list_state: ListState<Self>,
67 subscriptions: Vec<Subscription>,
68 collapsed_sections: Vec<Section>,
69 workspace: WeakViewHandle<Workspace>,
70}
71
72#[derive(Serialize, Deserialize)]
73struct SerializedChannelsPanel {
74 width: Option<f32>,
75}
76
77#[derive(Debug)]
78pub enum Event {
79 DockPositionChanged,
80 Focus,
81 Dismissed,
82}
83
84#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
85enum Section {
86 ActiveCall,
87 Requests,
88 Online,
89 Offline,
90}
91
92#[derive(Clone)]
93enum ContactEntry {
94 Header(Section),
95 CallParticipant {
96 user: Arc<User>,
97 is_pending: bool,
98 },
99 ParticipantProject {
100 project_id: u64,
101 worktree_root_names: Vec<String>,
102 host_user_id: u64,
103 is_last: bool,
104 },
105 ParticipantScreen {
106 peer_id: PeerId,
107 is_last: bool,
108 },
109 IncomingRequest(Arc<User>),
110 OutgoingRequest(Arc<User>),
111 Contact {
112 contact: Arc<Contact>,
113 calling: bool,
114 },
115}
116
117impl Entity for CollabPanel {
118 type Event = Event;
119}
120
121impl CollabPanel {
122 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
123 cx.add_view::<Self, _>(|cx| {
124 let view_id = cx.view_id();
125
126 let filter_editor = cx.add_view(|cx| {
127 let mut editor = Editor::single_line(
128 Some(Arc::new(|theme| {
129 theme.contact_list.user_query_editor.clone()
130 })),
131 cx,
132 );
133 editor.set_placeholder_text("Filter contacts", cx);
134 editor
135 });
136
137 cx.subscribe(&filter_editor, |this, _, event, cx| {
138 if let editor::Event::BufferEdited = event {
139 let query = this.filter_editor.read(cx).text(cx);
140 if !query.is_empty() {
141 this.selection.take();
142 }
143 this.update_entries(cx);
144 if !query.is_empty() {
145 this.selection = this
146 .entries
147 .iter()
148 .position(|entry| !matches!(entry, ContactEntry::Header(_)));
149 }
150 }
151 })
152 .detach();
153
154 let list_state =
155 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
156 let theme = theme::current(cx).clone();
157 let is_selected = this.selection == Some(ix);
158 let current_project_id = this.project.read(cx).remote_id();
159
160 match &this.entries[ix] {
161 ContactEntry::Header(section) => {
162 let is_collapsed = this.collapsed_sections.contains(section);
163 Self::render_header(
164 *section,
165 &theme.contact_list,
166 is_selected,
167 is_collapsed,
168 cx,
169 )
170 }
171 ContactEntry::CallParticipant { user, is_pending } => {
172 Self::render_call_participant(
173 user,
174 *is_pending,
175 is_selected,
176 &theme.contact_list,
177 )
178 }
179 ContactEntry::ParticipantProject {
180 project_id,
181 worktree_root_names,
182 host_user_id,
183 is_last,
184 } => Self::render_participant_project(
185 *project_id,
186 worktree_root_names,
187 *host_user_id,
188 Some(*project_id) == current_project_id,
189 *is_last,
190 is_selected,
191 &theme.contact_list,
192 cx,
193 ),
194 ContactEntry::ParticipantScreen { peer_id, is_last } => {
195 Self::render_participant_screen(
196 *peer_id,
197 *is_last,
198 is_selected,
199 &theme.contact_list,
200 cx,
201 )
202 }
203 ContactEntry::IncomingRequest(user) => Self::render_contact_request(
204 user.clone(),
205 this.user_store.clone(),
206 &theme.contact_list,
207 true,
208 is_selected,
209 cx,
210 ),
211 ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
212 user.clone(),
213 this.user_store.clone(),
214 &theme.contact_list,
215 false,
216 is_selected,
217 cx,
218 ),
219 ContactEntry::Contact { contact, calling } => Self::render_contact(
220 contact,
221 *calling,
222 &this.project,
223 &theme.contact_list,
224 is_selected,
225 cx,
226 ),
227 }
228 });
229
230 let mut this = Self {
231 width: None,
232 has_focus: false,
233 fs: workspace.app_state().fs.clone(),
234 pending_serialization: Task::ready(None),
235 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
236 filter_editor,
237 contact_finder: None,
238 entries: Vec::default(),
239 selection: None,
240 user_store: workspace.user_store().clone(),
241 project: workspace.project().clone(),
242 subscriptions: Vec::default(),
243 match_candidates: Vec::default(),
244 collapsed_sections: Vec::default(),
245 workspace: workspace.weak_handle(),
246 list_state,
247 };
248 this.update_entries(cx);
249
250 // Update the dock position when the setting changes.
251 let mut old_dock_position = this.position(cx);
252 this.subscriptions
253 .push(
254 cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
255 let new_dock_position = this.position(cx);
256 if new_dock_position != old_dock_position {
257 old_dock_position = new_dock_position;
258 cx.emit(Event::DockPositionChanged);
259 }
260 }),
261 );
262
263 let active_call = ActiveCall::global(cx);
264 this.subscriptions
265 .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx)));
266 this.subscriptions
267 .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
268
269 this
270 })
271 }
272
273 pub fn load(
274 workspace: WeakViewHandle<Workspace>,
275 cx: AsyncAppContext,
276 ) -> Task<Result<ViewHandle<Self>>> {
277 cx.spawn(|mut cx| async move {
278 let serialized_panel = if let Some(panel) = cx
279 .background()
280 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
281 .await
282 .log_err()
283 .flatten()
284 {
285 Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
286 } else {
287 None
288 };
289
290 workspace.update(&mut cx, |workspace, cx| {
291 let panel = CollabPanel::new(workspace, cx);
292 if let Some(serialized_panel) = serialized_panel {
293 panel.update(cx, |panel, cx| {
294 panel.width = serialized_panel.width;
295 cx.notify();
296 });
297 }
298 panel
299 })
300 })
301 }
302
303 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
304 let width = self.width;
305 self.pending_serialization = cx.background().spawn(
306 async move {
307 KEY_VALUE_STORE
308 .write_kvp(
309 CHANNELS_PANEL_KEY.into(),
310 serde_json::to_string(&SerializedChannelsPanel { width })?,
311 )
312 .await?;
313 anyhow::Ok(())
314 }
315 .log_err(),
316 );
317 }
318
319 fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
320 let user_store = self.user_store.read(cx);
321 let query = self.filter_editor.read(cx).text(cx);
322 let executor = cx.background().clone();
323
324 let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
325 let old_entries = mem::take(&mut self.entries);
326
327 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
328 let room = room.read(cx);
329 let mut participant_entries = Vec::new();
330
331 // Populate the active user.
332 if let Some(user) = user_store.current_user() {
333 self.match_candidates.clear();
334 self.match_candidates.push(StringMatchCandidate {
335 id: 0,
336 string: user.github_login.clone(),
337 char_bag: user.github_login.chars().collect(),
338 });
339 let matches = executor.block(match_strings(
340 &self.match_candidates,
341 &query,
342 true,
343 usize::MAX,
344 &Default::default(),
345 executor.clone(),
346 ));
347 if !matches.is_empty() {
348 let user_id = user.id;
349 participant_entries.push(ContactEntry::CallParticipant {
350 user,
351 is_pending: false,
352 });
353 let mut projects = room.local_participant().projects.iter().peekable();
354 while let Some(project) = projects.next() {
355 participant_entries.push(ContactEntry::ParticipantProject {
356 project_id: project.id,
357 worktree_root_names: project.worktree_root_names.clone(),
358 host_user_id: user_id,
359 is_last: projects.peek().is_none(),
360 });
361 }
362 }
363 }
364
365 // Populate remote participants.
366 self.match_candidates.clear();
367 self.match_candidates
368 .extend(room.remote_participants().iter().map(|(_, participant)| {
369 StringMatchCandidate {
370 id: participant.user.id as usize,
371 string: participant.user.github_login.clone(),
372 char_bag: participant.user.github_login.chars().collect(),
373 }
374 }));
375 let matches = executor.block(match_strings(
376 &self.match_candidates,
377 &query,
378 true,
379 usize::MAX,
380 &Default::default(),
381 executor.clone(),
382 ));
383 for mat in matches {
384 let user_id = mat.candidate_id as u64;
385 let participant = &room.remote_participants()[&user_id];
386 participant_entries.push(ContactEntry::CallParticipant {
387 user: participant.user.clone(),
388 is_pending: false,
389 });
390 let mut projects = participant.projects.iter().peekable();
391 while let Some(project) = projects.next() {
392 participant_entries.push(ContactEntry::ParticipantProject {
393 project_id: project.id,
394 worktree_root_names: project.worktree_root_names.clone(),
395 host_user_id: participant.user.id,
396 is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
397 });
398 }
399 if !participant.video_tracks.is_empty() {
400 participant_entries.push(ContactEntry::ParticipantScreen {
401 peer_id: participant.peer_id,
402 is_last: true,
403 });
404 }
405 }
406
407 // Populate pending participants.
408 self.match_candidates.clear();
409 self.match_candidates
410 .extend(
411 room.pending_participants()
412 .iter()
413 .enumerate()
414 .map(|(id, participant)| StringMatchCandidate {
415 id,
416 string: participant.github_login.clone(),
417 char_bag: participant.github_login.chars().collect(),
418 }),
419 );
420 let matches = executor.block(match_strings(
421 &self.match_candidates,
422 &query,
423 true,
424 usize::MAX,
425 &Default::default(),
426 executor.clone(),
427 ));
428 participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
429 user: room.pending_participants()[mat.candidate_id].clone(),
430 is_pending: true,
431 }));
432
433 if !participant_entries.is_empty() {
434 self.entries.push(ContactEntry::Header(Section::ActiveCall));
435 if !self.collapsed_sections.contains(&Section::ActiveCall) {
436 self.entries.extend(participant_entries);
437 }
438 }
439 }
440
441 let mut request_entries = Vec::new();
442 let incoming = user_store.incoming_contact_requests();
443 if !incoming.is_empty() {
444 self.match_candidates.clear();
445 self.match_candidates
446 .extend(
447 incoming
448 .iter()
449 .enumerate()
450 .map(|(ix, user)| StringMatchCandidate {
451 id: ix,
452 string: user.github_login.clone(),
453 char_bag: user.github_login.chars().collect(),
454 }),
455 );
456 let matches = executor.block(match_strings(
457 &self.match_candidates,
458 &query,
459 true,
460 usize::MAX,
461 &Default::default(),
462 executor.clone(),
463 ));
464 request_entries.extend(
465 matches
466 .iter()
467 .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
468 );
469 }
470
471 let outgoing = user_store.outgoing_contact_requests();
472 if !outgoing.is_empty() {
473 self.match_candidates.clear();
474 self.match_candidates
475 .extend(
476 outgoing
477 .iter()
478 .enumerate()
479 .map(|(ix, user)| StringMatchCandidate {
480 id: ix,
481 string: user.github_login.clone(),
482 char_bag: user.github_login.chars().collect(),
483 }),
484 );
485 let matches = executor.block(match_strings(
486 &self.match_candidates,
487 &query,
488 true,
489 usize::MAX,
490 &Default::default(),
491 executor.clone(),
492 ));
493 request_entries.extend(
494 matches
495 .iter()
496 .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
497 );
498 }
499
500 if !request_entries.is_empty() {
501 self.entries.push(ContactEntry::Header(Section::Requests));
502 if !self.collapsed_sections.contains(&Section::Requests) {
503 self.entries.append(&mut request_entries);
504 }
505 }
506
507 let contacts = user_store.contacts();
508 if !contacts.is_empty() {
509 self.match_candidates.clear();
510 self.match_candidates
511 .extend(
512 contacts
513 .iter()
514 .enumerate()
515 .map(|(ix, contact)| StringMatchCandidate {
516 id: ix,
517 string: contact.user.github_login.clone(),
518 char_bag: contact.user.github_login.chars().collect(),
519 }),
520 );
521
522 let matches = executor.block(match_strings(
523 &self.match_candidates,
524 &query,
525 true,
526 usize::MAX,
527 &Default::default(),
528 executor.clone(),
529 ));
530
531 let (mut online_contacts, offline_contacts) = matches
532 .iter()
533 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
534 if let Some(room) = ActiveCall::global(cx).read(cx).room() {
535 let room = room.read(cx);
536 online_contacts.retain(|contact| {
537 let contact = &contacts[contact.candidate_id];
538 !room.contains_participant(contact.user.id)
539 });
540 }
541
542 for (matches, section) in [
543 (online_contacts, Section::Online),
544 (offline_contacts, Section::Offline),
545 ] {
546 if !matches.is_empty() {
547 self.entries.push(ContactEntry::Header(section));
548 if !self.collapsed_sections.contains(§ion) {
549 let active_call = &ActiveCall::global(cx).read(cx);
550 for mat in matches {
551 let contact = &contacts[mat.candidate_id];
552 self.entries.push(ContactEntry::Contact {
553 contact: contact.clone(),
554 calling: active_call.pending_invites().contains(&contact.user.id),
555 });
556 }
557 }
558 }
559 }
560 }
561
562 if let Some(prev_selected_entry) = prev_selected_entry {
563 self.selection.take();
564 for (ix, entry) in self.entries.iter().enumerate() {
565 if *entry == prev_selected_entry {
566 self.selection = Some(ix);
567 break;
568 }
569 }
570 }
571
572 let old_scroll_top = self.list_state.logical_scroll_top();
573 self.list_state.reset(self.entries.len());
574
575 // Attempt to maintain the same scroll position.
576 if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
577 let new_scroll_top = self
578 .entries
579 .iter()
580 .position(|entry| entry == old_top_entry)
581 .map(|item_ix| ListOffset {
582 item_ix,
583 offset_in_item: old_scroll_top.offset_in_item,
584 })
585 .or_else(|| {
586 let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
587 let item_ix = self
588 .entries
589 .iter()
590 .position(|entry| entry == entry_after_old_top)?;
591 Some(ListOffset {
592 item_ix,
593 offset_in_item: 0.,
594 })
595 })
596 .or_else(|| {
597 let entry_before_old_top =
598 old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
599 let item_ix = self
600 .entries
601 .iter()
602 .position(|entry| entry == entry_before_old_top)?;
603 Some(ListOffset {
604 item_ix,
605 offset_in_item: 0.,
606 })
607 });
608
609 self.list_state
610 .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
611 }
612
613 cx.notify();
614 }
615
616 fn render_call_participant(
617 user: &User,
618 is_pending: bool,
619 is_selected: bool,
620 theme: &theme::ContactList,
621 ) -> AnyElement<Self> {
622 Flex::row()
623 .with_children(user.avatar.clone().map(|avatar| {
624 Image::from_data(avatar)
625 .with_style(theme.contact_avatar)
626 .aligned()
627 .left()
628 }))
629 .with_child(
630 Label::new(
631 user.github_login.clone(),
632 theme.contact_username.text.clone(),
633 )
634 .contained()
635 .with_style(theme.contact_username.container)
636 .aligned()
637 .left()
638 .flex(1., true),
639 )
640 .with_children(if is_pending {
641 Some(
642 Label::new("Calling", theme.calling_indicator.text.clone())
643 .contained()
644 .with_style(theme.calling_indicator.container)
645 .aligned(),
646 )
647 } else {
648 None
649 })
650 .constrained()
651 .with_height(theme.row_height)
652 .contained()
653 .with_style(
654 *theme
655 .contact_row
656 .in_state(is_selected)
657 .style_for(&mut Default::default()),
658 )
659 .into_any()
660 }
661
662 fn render_participant_project(
663 project_id: u64,
664 worktree_root_names: &[String],
665 host_user_id: u64,
666 is_current: bool,
667 is_last: bool,
668 is_selected: bool,
669 theme: &theme::ContactList,
670 cx: &mut ViewContext<Self>,
671 ) -> AnyElement<Self> {
672 enum JoinProject {}
673
674 let font_cache = cx.font_cache();
675 let host_avatar_height = theme
676 .contact_avatar
677 .width
678 .or(theme.contact_avatar.height)
679 .unwrap_or(0.);
680 let row = &theme.project_row.inactive_state().default;
681 let tree_branch = theme.tree_branch;
682 let line_height = row.name.text.line_height(font_cache);
683 let cap_height = row.name.text.cap_height(font_cache);
684 let baseline_offset =
685 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
686 let project_name = if worktree_root_names.is_empty() {
687 "untitled".to_string()
688 } else {
689 worktree_root_names.join(", ")
690 };
691
692 MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
693 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
694 let row = theme
695 .project_row
696 .in_state(is_selected)
697 .style_for(mouse_state);
698
699 Flex::row()
700 .with_child(
701 Stack::new()
702 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
703 let start_x =
704 bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
705 let end_x = bounds.max_x();
706 let start_y = bounds.min_y();
707 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
708
709 scene.push_quad(gpui::Quad {
710 bounds: RectF::from_points(
711 vec2f(start_x, start_y),
712 vec2f(
713 start_x + tree_branch.width,
714 if is_last { end_y } else { bounds.max_y() },
715 ),
716 ),
717 background: Some(tree_branch.color),
718 border: gpui::Border::default(),
719 corner_radius: 0.,
720 });
721 scene.push_quad(gpui::Quad {
722 bounds: RectF::from_points(
723 vec2f(start_x, end_y),
724 vec2f(end_x, end_y + tree_branch.width),
725 ),
726 background: Some(tree_branch.color),
727 border: gpui::Border::default(),
728 corner_radius: 0.,
729 });
730 }))
731 .constrained()
732 .with_width(host_avatar_height),
733 )
734 .with_child(
735 Label::new(project_name, row.name.text.clone())
736 .aligned()
737 .left()
738 .contained()
739 .with_style(row.name.container)
740 .flex(1., false),
741 )
742 .constrained()
743 .with_height(theme.row_height)
744 .contained()
745 .with_style(row.container)
746 })
747 .with_cursor_style(if !is_current {
748 CursorStyle::PointingHand
749 } else {
750 CursorStyle::Arrow
751 })
752 .on_click(MouseButton::Left, move |_, this, cx| {
753 if !is_current {
754 if let Some(workspace) = this.workspace.upgrade(cx) {
755 let app_state = workspace.read(cx).app_state().clone();
756 workspace::join_remote_project(project_id, host_user_id, app_state, cx)
757 .detach_and_log_err(cx);
758 }
759 }
760 })
761 .into_any()
762 }
763
764 fn render_participant_screen(
765 peer_id: PeerId,
766 is_last: bool,
767 is_selected: bool,
768 theme: &theme::ContactList,
769 cx: &mut ViewContext<Self>,
770 ) -> AnyElement<Self> {
771 enum OpenSharedScreen {}
772
773 let font_cache = cx.font_cache();
774 let host_avatar_height = theme
775 .contact_avatar
776 .width
777 .or(theme.contact_avatar.height)
778 .unwrap_or(0.);
779 let row = &theme.project_row.inactive_state().default;
780 let tree_branch = theme.tree_branch;
781 let line_height = row.name.text.line_height(font_cache);
782 let cap_height = row.name.text.cap_height(font_cache);
783 let baseline_offset =
784 row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
785
786 MouseEventHandler::<OpenSharedScreen, Self>::new(
787 peer_id.as_u64() as usize,
788 cx,
789 |mouse_state, _| {
790 let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
791 let row = theme
792 .project_row
793 .in_state(is_selected)
794 .style_for(mouse_state);
795
796 Flex::row()
797 .with_child(
798 Stack::new()
799 .with_child(Canvas::new(move |scene, bounds, _, _, _| {
800 let start_x = bounds.min_x() + (bounds.width() / 2.)
801 - (tree_branch.width / 2.);
802 let end_x = bounds.max_x();
803 let start_y = bounds.min_y();
804 let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
805
806 scene.push_quad(gpui::Quad {
807 bounds: RectF::from_points(
808 vec2f(start_x, start_y),
809 vec2f(
810 start_x + tree_branch.width,
811 if is_last { end_y } else { bounds.max_y() },
812 ),
813 ),
814 background: Some(tree_branch.color),
815 border: gpui::Border::default(),
816 corner_radius: 0.,
817 });
818 scene.push_quad(gpui::Quad {
819 bounds: RectF::from_points(
820 vec2f(start_x, end_y),
821 vec2f(end_x, end_y + tree_branch.width),
822 ),
823 background: Some(tree_branch.color),
824 border: gpui::Border::default(),
825 corner_radius: 0.,
826 });
827 }))
828 .constrained()
829 .with_width(host_avatar_height),
830 )
831 .with_child(
832 Svg::new("icons/disable_screen_sharing_12.svg")
833 .with_color(row.icon.color)
834 .constrained()
835 .with_width(row.icon.width)
836 .aligned()
837 .left()
838 .contained()
839 .with_style(row.icon.container),
840 )
841 .with_child(
842 Label::new("Screen", row.name.text.clone())
843 .aligned()
844 .left()
845 .contained()
846 .with_style(row.name.container)
847 .flex(1., false),
848 )
849 .constrained()
850 .with_height(theme.row_height)
851 .contained()
852 .with_style(row.container)
853 },
854 )
855 .with_cursor_style(CursorStyle::PointingHand)
856 .on_click(MouseButton::Left, move |_, this, cx| {
857 if let Some(workspace) = this.workspace.upgrade(cx) {
858 workspace.update(cx, |workspace, cx| {
859 workspace.open_shared_screen(peer_id, cx)
860 });
861 }
862 })
863 .into_any()
864 }
865
866 fn render_header(
867 section: Section,
868 theme: &theme::ContactList,
869 is_selected: bool,
870 is_collapsed: bool,
871 cx: &mut ViewContext<Self>,
872 ) -> AnyElement<Self> {
873 enum Header {}
874 enum LeaveCallContactList {}
875
876 let header_style = theme
877 .header_row
878 .in_state(is_selected)
879 .style_for(&mut Default::default());
880 let text = match section {
881 Section::ActiveCall => "Collaborators",
882 Section::Requests => "Contact Requests",
883 Section::Online => "Online",
884 Section::Offline => "Offline",
885 };
886 let leave_call = if section == Section::ActiveCall {
887 Some(
888 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
889 let style = theme.leave_call.style_for(state);
890 Label::new("Leave Call", style.text.clone())
891 .contained()
892 .with_style(style.container)
893 })
894 .on_click(MouseButton::Left, |_, _, cx| {
895 ActiveCall::global(cx)
896 .update(cx, |call, cx| call.hang_up(cx))
897 .detach_and_log_err(cx);
898 })
899 .aligned(),
900 )
901 } else {
902 None
903 };
904
905 let icon_size = theme.section_icon_size;
906 MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
907 Flex::row()
908 .with_child(
909 Svg::new(if is_collapsed {
910 "icons/chevron_right_8.svg"
911 } else {
912 "icons/chevron_down_8.svg"
913 })
914 .with_color(header_style.text.color)
915 .constrained()
916 .with_max_width(icon_size)
917 .with_max_height(icon_size)
918 .aligned()
919 .constrained()
920 .with_width(icon_size),
921 )
922 .with_child(
923 Label::new(text, header_style.text.clone())
924 .aligned()
925 .left()
926 .contained()
927 .with_margin_left(theme.contact_username.container.margin.left)
928 .flex(1., true),
929 )
930 .with_children(leave_call)
931 .constrained()
932 .with_height(theme.row_height)
933 .contained()
934 .with_style(header_style.container)
935 })
936 .with_cursor_style(CursorStyle::PointingHand)
937 .on_click(MouseButton::Left, move |_, this, cx| {
938 this.toggle_expanded(section, cx);
939 })
940 .into_any()
941 }
942
943 fn render_contact(
944 contact: &Contact,
945 calling: bool,
946 project: &ModelHandle<Project>,
947 theme: &theme::ContactList,
948 is_selected: bool,
949 cx: &mut ViewContext<Self>,
950 ) -> AnyElement<Self> {
951 let online = contact.online;
952 let busy = contact.busy || calling;
953 let user_id = contact.user.id;
954 let github_login = contact.user.github_login.clone();
955 let initial_project = project.clone();
956 let mut event_handler =
957 MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
958 Flex::row()
959 .with_children(contact.user.avatar.clone().map(|avatar| {
960 let status_badge = if contact.online {
961 Some(
962 Empty::new()
963 .collapsed()
964 .contained()
965 .with_style(if busy {
966 theme.contact_status_busy
967 } else {
968 theme.contact_status_free
969 })
970 .aligned(),
971 )
972 } else {
973 None
974 };
975 Stack::new()
976 .with_child(
977 Image::from_data(avatar)
978 .with_style(theme.contact_avatar)
979 .aligned()
980 .left(),
981 )
982 .with_children(status_badge)
983 }))
984 .with_child(
985 Label::new(
986 contact.user.github_login.clone(),
987 theme.contact_username.text.clone(),
988 )
989 .contained()
990 .with_style(theme.contact_username.container)
991 .aligned()
992 .left()
993 .flex(1., true),
994 )
995 .with_child(
996 MouseEventHandler::<Cancel, Self>::new(
997 contact.user.id as usize,
998 cx,
999 |mouse_state, _| {
1000 let button_style = theme.contact_button.style_for(mouse_state);
1001 render_icon_button(button_style, "icons/x_mark_8.svg")
1002 .aligned()
1003 .flex_float()
1004 },
1005 )
1006 .with_padding(Padding::uniform(2.))
1007 .with_cursor_style(CursorStyle::PointingHand)
1008 .on_click(MouseButton::Left, move |_, this, cx| {
1009 this.remove_contact(user_id, &github_login, cx);
1010 })
1011 .flex_float(),
1012 )
1013 .with_children(if calling {
1014 Some(
1015 Label::new("Calling", theme.calling_indicator.text.clone())
1016 .contained()
1017 .with_style(theme.calling_indicator.container)
1018 .aligned(),
1019 )
1020 } else {
1021 None
1022 })
1023 .constrained()
1024 .with_height(theme.row_height)
1025 .contained()
1026 .with_style(
1027 *theme
1028 .contact_row
1029 .in_state(is_selected)
1030 .style_for(&mut Default::default()),
1031 )
1032 })
1033 .on_click(MouseButton::Left, move |_, this, cx| {
1034 if online && !busy {
1035 this.call(user_id, Some(initial_project.clone()), cx);
1036 }
1037 });
1038
1039 if online {
1040 event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
1041 }
1042
1043 event_handler.into_any()
1044 }
1045
1046 fn render_contact_request(
1047 user: Arc<User>,
1048 user_store: ModelHandle<UserStore>,
1049 theme: &theme::ContactList,
1050 is_incoming: bool,
1051 is_selected: bool,
1052 cx: &mut ViewContext<Self>,
1053 ) -> AnyElement<Self> {
1054 enum Decline {}
1055 enum Accept {}
1056 enum Cancel {}
1057
1058 let mut row = Flex::row()
1059 .with_children(user.avatar.clone().map(|avatar| {
1060 Image::from_data(avatar)
1061 .with_style(theme.contact_avatar)
1062 .aligned()
1063 .left()
1064 }))
1065 .with_child(
1066 Label::new(
1067 user.github_login.clone(),
1068 theme.contact_username.text.clone(),
1069 )
1070 .contained()
1071 .with_style(theme.contact_username.container)
1072 .aligned()
1073 .left()
1074 .flex(1., true),
1075 );
1076
1077 let user_id = user.id;
1078 let github_login = user.github_login.clone();
1079 let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
1080 let button_spacing = theme.contact_button_spacing;
1081
1082 if is_incoming {
1083 row.add_child(
1084 MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
1085 let button_style = if is_contact_request_pending {
1086 &theme.disabled_button
1087 } else {
1088 theme.contact_button.style_for(mouse_state)
1089 };
1090 render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
1091 })
1092 .with_cursor_style(CursorStyle::PointingHand)
1093 .on_click(MouseButton::Left, move |_, this, cx| {
1094 this.respond_to_contact_request(user_id, false, cx);
1095 })
1096 .contained()
1097 .with_margin_right(button_spacing),
1098 );
1099
1100 row.add_child(
1101 MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
1102 let button_style = if is_contact_request_pending {
1103 &theme.disabled_button
1104 } else {
1105 theme.contact_button.style_for(mouse_state)
1106 };
1107 render_icon_button(button_style, "icons/check_8.svg")
1108 .aligned()
1109 .flex_float()
1110 })
1111 .with_cursor_style(CursorStyle::PointingHand)
1112 .on_click(MouseButton::Left, move |_, this, cx| {
1113 this.respond_to_contact_request(user_id, true, cx);
1114 }),
1115 );
1116 } else {
1117 row.add_child(
1118 MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
1119 let button_style = if is_contact_request_pending {
1120 &theme.disabled_button
1121 } else {
1122 theme.contact_button.style_for(mouse_state)
1123 };
1124 render_icon_button(button_style, "icons/x_mark_8.svg")
1125 .aligned()
1126 .flex_float()
1127 })
1128 .with_padding(Padding::uniform(2.))
1129 .with_cursor_style(CursorStyle::PointingHand)
1130 .on_click(MouseButton::Left, move |_, this, cx| {
1131 this.remove_contact(user_id, &github_login, cx);
1132 })
1133 .flex_float(),
1134 );
1135 }
1136
1137 row.constrained()
1138 .with_height(theme.row_height)
1139 .contained()
1140 .with_style(
1141 *theme
1142 .contact_row
1143 .in_state(is_selected)
1144 .style_for(&mut Default::default()),
1145 )
1146 .into_any()
1147 }
1148
1149 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
1150 if self.contact_finder.take().is_some() {
1151 cx.notify();
1152 return;
1153 }
1154
1155 let did_clear = self.filter_editor.update(cx, |editor, cx| {
1156 if editor.buffer().read(cx).len(cx) > 0 {
1157 editor.set_text("", cx);
1158 true
1159 } else {
1160 false
1161 }
1162 });
1163
1164 if !did_clear {
1165 cx.emit(Event::Dismissed);
1166 }
1167 }
1168
1169 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1170 if let Some(ix) = self.selection {
1171 if self.entries.len() > ix + 1 {
1172 self.selection = Some(ix + 1);
1173 }
1174 } else if !self.entries.is_empty() {
1175 self.selection = Some(0);
1176 }
1177 self.list_state.reset(self.entries.len());
1178 if let Some(ix) = self.selection {
1179 self.list_state.scroll_to(ListOffset {
1180 item_ix: ix,
1181 offset_in_item: 0.,
1182 });
1183 }
1184 cx.notify();
1185 }
1186
1187 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1188 if let Some(ix) = self.selection {
1189 if ix > 0 {
1190 self.selection = Some(ix - 1);
1191 } else {
1192 self.selection = None;
1193 }
1194 }
1195 self.list_state.reset(self.entries.len());
1196 if let Some(ix) = self.selection {
1197 self.list_state.scroll_to(ListOffset {
1198 item_ix: ix,
1199 offset_in_item: 0.,
1200 });
1201 }
1202 cx.notify();
1203 }
1204
1205 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1206 if let Some(selection) = self.selection {
1207 if let Some(entry) = self.entries.get(selection) {
1208 match entry {
1209 ContactEntry::Header(section) => {
1210 self.toggle_expanded(*section, cx);
1211 }
1212 ContactEntry::Contact { contact, calling } => {
1213 if contact.online && !contact.busy && !calling {
1214 self.call(contact.user.id, Some(self.project.clone()), cx);
1215 }
1216 }
1217 ContactEntry::ParticipantProject {
1218 project_id,
1219 host_user_id,
1220 ..
1221 } => {
1222 if let Some(workspace) = self.workspace.upgrade(cx) {
1223 let app_state = workspace.read(cx).app_state().clone();
1224 workspace::join_remote_project(
1225 *project_id,
1226 *host_user_id,
1227 app_state,
1228 cx,
1229 )
1230 .detach_and_log_err(cx);
1231 }
1232 }
1233 ContactEntry::ParticipantScreen { peer_id, .. } => {
1234 if let Some(workspace) = self.workspace.upgrade(cx) {
1235 workspace.update(cx, |workspace, cx| {
1236 workspace.open_shared_screen(*peer_id, cx)
1237 });
1238 }
1239 }
1240 _ => {}
1241 }
1242 }
1243 }
1244 }
1245
1246 fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
1247 if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
1248 self.collapsed_sections.remove(ix);
1249 } else {
1250 self.collapsed_sections.push(section);
1251 }
1252 self.update_entries(cx);
1253 }
1254
1255 fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
1256 if self.contact_finder.take().is_none() {
1257 let child = cx.add_view(|cx| {
1258 let finder = build_contact_finder(self.user_store.clone(), cx);
1259 finder.set_query(self.filter_editor.read(cx).text(cx), cx);
1260 finder
1261 });
1262 cx.focus(&child);
1263 // self.subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
1264 // // PickerEvent::Dismiss => cx.emit(Event::Dismissed),
1265 // }));
1266 self.contact_finder = Some(child);
1267 }
1268 cx.notify();
1269 }
1270
1271 fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
1272 let user_store = self.user_store.clone();
1273 let prompt_message = format!(
1274 "Are you sure you want to remove \"{}\" from your contacts?",
1275 github_login
1276 );
1277 let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
1278 let window_id = cx.window_id();
1279 cx.spawn(|_, mut cx| async move {
1280 if answer.next().await == Some(0) {
1281 if let Err(e) = user_store
1282 .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
1283 .await
1284 {
1285 cx.prompt(
1286 window_id,
1287 PromptLevel::Info,
1288 &format!("Failed to remove contact: {}", e),
1289 &["Ok"],
1290 );
1291 }
1292 }
1293 })
1294 .detach();
1295 }
1296
1297 fn respond_to_contact_request(
1298 &mut self,
1299 user_id: u64,
1300 accept: bool,
1301 cx: &mut ViewContext<Self>,
1302 ) {
1303 self.user_store
1304 .update(cx, |store, cx| {
1305 store.respond_to_contact_request(user_id, accept, cx)
1306 })
1307 .detach();
1308 }
1309
1310 fn call(
1311 &mut self,
1312 recipient_user_id: u64,
1313 initial_project: Option<ModelHandle<Project>>,
1314 cx: &mut ViewContext<Self>,
1315 ) {
1316 ActiveCall::global(cx)
1317 .update(cx, |call, cx| {
1318 call.invite(recipient_user_id, initial_project, cx)
1319 })
1320 .detach_and_log_err(cx);
1321 }
1322}
1323
1324impl View for CollabPanel {
1325 fn ui_name() -> &'static str {
1326 "CollabPanel"
1327 }
1328
1329 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1330 if !self.has_focus {
1331 self.has_focus = true;
1332 cx.emit(Event::Focus);
1333 }
1334 }
1335
1336 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1337 self.has_focus = false;
1338 }
1339
1340 fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
1341 enum AddContact {}
1342 let theme = theme::current(cx).clone();
1343
1344 Stack::new()
1345 .with_child(if let Some(finder) = &self.contact_finder {
1346 ChildView::new(&finder, cx).into_any()
1347 } else {
1348 Flex::column()
1349 .with_child(
1350 Flex::row()
1351 .with_child(
1352 ChildView::new(&self.filter_editor, cx)
1353 .contained()
1354 .with_style(theme.contact_list.user_query_editor.container)
1355 .flex(1.0, true),
1356 )
1357 .with_child(
1358 MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
1359 render_icon_button(
1360 &theme.contact_list.add_contact_button,
1361 "icons/user_plus_16.svg",
1362 )
1363 })
1364 .with_cursor_style(CursorStyle::PointingHand)
1365 .on_click(MouseButton::Left, |_, this, cx| {
1366 this.toggle_contact_finder(cx);
1367 })
1368 .with_tooltip::<AddContact>(
1369 0,
1370 "Search for new contact".into(),
1371 None,
1372 theme.tooltip.clone(),
1373 cx,
1374 )
1375 .constrained()
1376 .with_height(theme.contact_list.user_query_editor_height)
1377 .with_width(theme.contact_list.user_query_editor_height),
1378 )
1379 .constrained()
1380 .with_width(self.size(cx)),
1381 )
1382 .with_child(
1383 List::new(self.list_state.clone())
1384 .constrained()
1385 .with_width(self.size(cx))
1386 .flex(1., true)
1387 .into_any(),
1388 )
1389 .constrained()
1390 .with_width(self.size(cx))
1391 .into_any()
1392 })
1393 .with_child(ChildView::new(&self.context_menu, cx))
1394 .into_any_named("channels panel")
1395 .into_any()
1396 }
1397}
1398
1399impl Panel for CollabPanel {
1400 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
1401 match settings::get::<ChannelsPanelSettings>(cx).dock {
1402 ChannelsPanelDockPosition::Left => DockPosition::Left,
1403 ChannelsPanelDockPosition::Right => DockPosition::Right,
1404 }
1405 }
1406
1407 fn position_is_valid(&self, position: DockPosition) -> bool {
1408 matches!(position, DockPosition::Left | DockPosition::Right)
1409 }
1410
1411 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1412 settings::update_settings_file::<ChannelsPanelSettings>(
1413 self.fs.clone(),
1414 cx,
1415 move |settings| {
1416 let dock = match position {
1417 DockPosition::Left | DockPosition::Bottom => ChannelsPanelDockPosition::Left,
1418 DockPosition::Right => ChannelsPanelDockPosition::Right,
1419 };
1420 settings.dock = Some(dock);
1421 },
1422 );
1423 }
1424
1425 fn size(&self, cx: &gpui::WindowContext) -> f32 {
1426 self.width
1427 .unwrap_or_else(|| settings::get::<ChannelsPanelSettings>(cx).default_width)
1428 }
1429
1430 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1431 self.width = Some(size);
1432 self.serialize(cx);
1433 cx.notify();
1434 }
1435
1436 fn icon_path(&self) -> &'static str {
1437 "icons/radix/person.svg"
1438 }
1439
1440 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
1441 ("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
1442 }
1443
1444 fn should_change_position_on_event(event: &Self::Event) -> bool {
1445 matches!(event, Event::DockPositionChanged)
1446 }
1447
1448 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
1449 self.has_focus
1450 }
1451
1452 fn is_focus_event(event: &Self::Event) -> bool {
1453 matches!(event, Event::Focus)
1454 }
1455}
1456
1457impl PartialEq for ContactEntry {
1458 fn eq(&self, other: &Self) -> bool {
1459 match self {
1460 ContactEntry::Header(section_1) => {
1461 if let ContactEntry::Header(section_2) = other {
1462 return section_1 == section_2;
1463 }
1464 }
1465 ContactEntry::CallParticipant { user: user_1, .. } => {
1466 if let ContactEntry::CallParticipant { user: user_2, .. } = other {
1467 return user_1.id == user_2.id;
1468 }
1469 }
1470 ContactEntry::ParticipantProject {
1471 project_id: project_id_1,
1472 ..
1473 } => {
1474 if let ContactEntry::ParticipantProject {
1475 project_id: project_id_2,
1476 ..
1477 } = other
1478 {
1479 return project_id_1 == project_id_2;
1480 }
1481 }
1482 ContactEntry::ParticipantScreen {
1483 peer_id: peer_id_1, ..
1484 } => {
1485 if let ContactEntry::ParticipantScreen {
1486 peer_id: peer_id_2, ..
1487 } = other
1488 {
1489 return peer_id_1 == peer_id_2;
1490 }
1491 }
1492 ContactEntry::IncomingRequest(user_1) => {
1493 if let ContactEntry::IncomingRequest(user_2) = other {
1494 return user_1.id == user_2.id;
1495 }
1496 }
1497 ContactEntry::OutgoingRequest(user_1) => {
1498 if let ContactEntry::OutgoingRequest(user_2) = other {
1499 return user_1.id == user_2.id;
1500 }
1501 }
1502 ContactEntry::Contact {
1503 contact: contact_1, ..
1504 } => {
1505 if let ContactEntry::Contact {
1506 contact: contact_2, ..
1507 } = other
1508 {
1509 return contact_1.user.id == contact_2.user.id;
1510 }
1511 }
1512 }
1513 false
1514 }
1515}
1516
1517fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
1518 Svg::new(svg_path)
1519 .with_color(style.color)
1520 .constrained()
1521 .with_width(style.icon_width)
1522 .aligned()
1523 .contained()
1524 .with_style(style.container)
1525 .constrained()
1526 .with_width(style.button_width)
1527 .with_height(style.button_width)
1528}