1use crate::{
2 contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
3 toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
4};
5use auto_update::AutoUpdateStatus;
6use call::{ActiveCall, ParticipantLocation, Room};
7use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
8use clock::ReplicaId;
9use context_menu::{ContextMenu, ContextMenuItem};
10use gpui::{
11 actions,
12 color::Color,
13 elements::*,
14 geometry::{rect::RectF, vector::vec2f, PathBuilder},
15 json::{self, ToJson},
16 platform::{CursorStyle, MouseButton},
17 AppContext, Entity, ImageData, ModelHandle, Subscription, View, ViewContext, ViewHandle,
18 WeakViewHandle,
19};
20use picker::PickerEvent;
21use project::{Project, RepositoryEntry};
22use recent_projects::{build_recent_projects, RecentProjects};
23use std::{ops::Range, sync::Arc};
24use theme::{AvatarStyle, Theme};
25use util::ResultExt;
26use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
27use workspace::{FollowNextCollaborator, Workspace, WORKSPACE_DB};
28
29const MAX_PROJECT_NAME_LENGTH: usize = 40;
30const MAX_BRANCH_NAME_LENGTH: usize = 40;
31
32actions!(
33 collab,
34 [
35 ToggleUserMenu,
36 ToggleProjectMenu,
37 SwitchBranch,
38 ShareProject,
39 UnshareProject,
40 ]
41);
42
43pub fn init(cx: &mut AppContext) {
44 cx.add_action(CollabTitlebarItem::share_project);
45 cx.add_action(CollabTitlebarItem::unshare_project);
46 cx.add_action(CollabTitlebarItem::toggle_user_menu);
47 cx.add_action(CollabTitlebarItem::toggle_vcs_menu);
48 cx.add_action(CollabTitlebarItem::toggle_project_menu);
49}
50
51pub struct CollabTitlebarItem {
52 project: ModelHandle<Project>,
53 user_store: ModelHandle<UserStore>,
54 client: Arc<Client>,
55 workspace: WeakViewHandle<Workspace>,
56 branch_popover: Option<ViewHandle<BranchList>>,
57 project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
58 user_menu: ViewHandle<ContextMenu>,
59 _subscriptions: Vec<Subscription>,
60}
61
62impl Entity for CollabTitlebarItem {
63 type Event = ();
64}
65
66impl View for CollabTitlebarItem {
67 fn ui_name() -> &'static str {
68 "CollabTitlebarItem"
69 }
70
71 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
72 let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
73 workspace
74 } else {
75 return Empty::new().into_any();
76 };
77
78 let theme = theme::current(cx).clone();
79 let mut left_container = Flex::row();
80 let mut right_container = Flex::row().align_children_center();
81
82 left_container.add_child(self.collect_title_root_names(theme.clone(), cx));
83
84 let user = self.user_store.read(cx).current_user();
85 let peer_id = self.client.peer_id();
86 if let Some(((user, peer_id), room)) = user
87 .as_ref()
88 .zip(peer_id)
89 .zip(ActiveCall::global(cx).read(cx).room().cloned())
90 {
91 right_container
92 .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
93 right_container.add_child(self.render_leave_call(&theme, cx));
94 let muted = room.read(cx).is_muted(cx);
95 let speaking = room.read(cx).is_speaking();
96 left_container.add_child(
97 self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
98 );
99 left_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
100 right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
101 right_container.add_child(self.render_toggle_deafen(&theme, &room, cx));
102 right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
103 }
104
105 let status = workspace.read(cx).client().status();
106 let status = &*status.borrow();
107 if matches!(status, client::Status::Connected { .. }) {
108 let avatar = user.as_ref().and_then(|user| user.avatar.clone());
109 right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
110 } else {
111 right_container.add_children(self.render_connection_status(status, cx));
112 right_container.add_child(self.render_sign_in_button(&theme, cx));
113 right_container.add_child(self.render_user_menu_button(&theme, None, cx));
114 }
115
116 Stack::new()
117 .with_child(left_container)
118 .with_child(
119 Flex::row()
120 .with_child(
121 right_container.contained().with_background_color(
122 theme
123 .titlebar
124 .container
125 .background_color
126 .unwrap_or_else(|| Color::transparent_black()),
127 ),
128 )
129 .aligned()
130 .right(),
131 )
132 .into_any()
133 }
134}
135
136impl CollabTitlebarItem {
137 pub fn new(
138 workspace: &Workspace,
139 workspace_handle: &ViewHandle<Workspace>,
140 cx: &mut ViewContext<Self>,
141 ) -> Self {
142 let project = workspace.project().clone();
143 let user_store = workspace.app_state().user_store.clone();
144 let client = workspace.app_state().client.clone();
145 let active_call = ActiveCall::global(cx);
146 let mut subscriptions = Vec::new();
147 subscriptions.push(cx.observe(workspace_handle, |_, _, cx| cx.notify()));
148 subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
149 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
150 subscriptions.push(cx.observe_window_activation(|this, active, cx| {
151 this.window_activation_changed(active, cx)
152 }));
153 subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
154 subscriptions.push(
155 cx.subscribe(&user_store, move |this, user_store, event, cx| {
156 if let Some(workspace) = this.workspace.upgrade(cx) {
157 workspace.update(cx, |workspace, cx| {
158 if let client::Event::Contact { user, kind } = event {
159 if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
160 workspace.show_notification(user.id as usize, cx, |cx| {
161 cx.add_view(|cx| {
162 ContactNotification::new(
163 user.clone(),
164 *kind,
165 user_store,
166 cx,
167 )
168 })
169 })
170 }
171 }
172 });
173 }
174 }),
175 );
176
177 Self {
178 workspace: workspace.weak_handle(),
179 project,
180 user_store,
181 client,
182 user_menu: cx.add_view(|cx| {
183 let view_id = cx.view_id();
184 let mut menu = ContextMenu::new(view_id, cx);
185 menu.set_position_mode(OverlayPositionMode::Local);
186 menu
187 }),
188 branch_popover: None,
189 project_popover: None,
190 _subscriptions: subscriptions,
191 }
192 }
193
194 fn collect_title_root_names(
195 &self,
196 theme: Arc<Theme>,
197 cx: &mut ViewContext<Self>,
198 ) -> AnyElement<Self> {
199 let project = self.project.read(cx);
200
201 let (name, entry) = {
202 let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
203 let worktree = worktree.read(cx);
204 (worktree.root_name(), worktree.root_git_entry())
205 });
206
207 names_and_branches.next().unwrap_or(("", None))
208 };
209
210 let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
211 let branch_prepended = entry
212 .as_ref()
213 .and_then(RepositoryEntry::branch)
214 .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH));
215 let project_style = theme.titlebar.project_menu_button.clone();
216 let git_style = theme.titlebar.git_menu_button.clone();
217 let item_spacing = theme.titlebar.item_spacing;
218
219 let mut ret = Flex::row();
220
221 if let Some(project_host) = self.collect_project_host(theme.clone(), cx) {
222 ret = ret.with_child(project_host)
223 }
224
225 ret = ret.with_child(
226 Stack::new()
227 .with_child(
228 MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
229 let style = project_style
230 .in_state(self.project_popover.is_some())
231 .style_for(mouse_state);
232 enum RecentProjectsTooltip {}
233 Label::new(name, style.text.clone())
234 .contained()
235 .with_style(style.container)
236 .aligned()
237 .left()
238 .with_tooltip::<RecentProjectsTooltip>(
239 0,
240 "Recent projects",
241 Some(Box::new(recent_projects::OpenRecent)),
242 theme.tooltip.clone(),
243 cx,
244 )
245 .into_any_named("title-project-name")
246 })
247 .with_cursor_style(CursorStyle::PointingHand)
248 .on_down(MouseButton::Left, move |_, this, cx| {
249 this.toggle_project_menu(&Default::default(), cx)
250 })
251 .on_click(MouseButton::Left, move |_, _, _| {}),
252 )
253 .with_children(self.render_project_popover_host(&theme.titlebar, cx)),
254 );
255 if let Some(git_branch) = branch_prepended {
256 ret = ret.with_child(
257 Flex::row().with_child(
258 Stack::new()
259 .with_child(
260 MouseEventHandler::new::<ToggleVcsMenu, _>(0, cx, |mouse_state, cx| {
261 enum BranchPopoverTooltip {}
262 let style = git_style
263 .in_state(self.branch_popover.is_some())
264 .style_for(mouse_state);
265 Label::new(git_branch, style.text.clone())
266 .contained()
267 .with_style(style.container.clone())
268 .with_margin_right(item_spacing)
269 .aligned()
270 .left()
271 .with_tooltip::<BranchPopoverTooltip>(
272 0,
273 "Recent branches",
274 Some(Box::new(ToggleVcsMenu)),
275 theme.tooltip.clone(),
276 cx,
277 )
278 .into_any_named("title-project-branch")
279 })
280 .with_cursor_style(CursorStyle::PointingHand)
281 .on_down(MouseButton::Left, move |_, this, cx| {
282 this.toggle_vcs_menu(&Default::default(), cx)
283 })
284 .on_click(MouseButton::Left, move |_, _, _| {}),
285 )
286 .with_children(self.render_branches_popover_host(&theme.titlebar, cx)),
287 ),
288 )
289 }
290 ret.into_any()
291 }
292
293 fn collect_project_host(
294 &self,
295 theme: Arc<Theme>,
296 cx: &mut ViewContext<Self>,
297 ) -> Option<AnyElement<Self>> {
298 if ActiveCall::global(cx).read(cx).room().is_none() {
299 return None;
300 }
301 let project = self.project.read(cx);
302 let user_store = self.user_store.read(cx);
303
304 if project.is_local() {
305 return None;
306 }
307
308 let Some(host) = project.host() else {
309 return None;
310 };
311 let (Some(host_user), Some(participant_index)) = (
312 user_store.get_cached_user(host.user_id),
313 user_store.participant_indices().get(&host.user_id),
314 ) else {
315 return None;
316 };
317
318 enum ProjectHost {}
319 enum ProjectHostTooltip {}
320
321 let host_style = theme.titlebar.project_host.clone();
322 let selection_style = theme
323 .editor
324 .selection_style_for_room_participant(participant_index.0);
325 let peer_id = host.peer_id.clone();
326
327 Some(
328 MouseEventHandler::new::<ProjectHost, _>(0, cx, |mouse_state, _| {
329 let mut host_style = host_style.style_for(mouse_state).clone();
330 host_style.text.color = selection_style.cursor;
331 Label::new(host_user.github_login.clone(), host_style.text)
332 .contained()
333 .with_style(host_style.container)
334 .aligned()
335 .left()
336 })
337 .with_cursor_style(CursorStyle::PointingHand)
338 .on_click(MouseButton::Left, move |_, this, cx| {
339 if let Some(workspace) = this.workspace.upgrade(cx) {
340 if let Some(task) =
341 workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
342 {
343 task.detach_and_log_err(cx);
344 }
345 }
346 })
347 .with_tooltip::<ProjectHostTooltip>(
348 0,
349 host_user.github_login.clone() + " is sharing this project. Click to follow.",
350 None,
351 theme.tooltip.clone(),
352 cx,
353 )
354 .into_any_named("project-host"),
355 )
356 }
357
358 fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
359 let project = if active {
360 Some(self.project.clone())
361 } else {
362 None
363 };
364 ActiveCall::global(cx)
365 .update(cx, |call, cx| call.set_location(project.as_ref(), cx))
366 .detach_and_log_err(cx);
367 }
368
369 fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
370 cx.notify();
371 }
372
373 fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
374 let active_call = ActiveCall::global(cx);
375 let project = self.project.clone();
376 active_call
377 .update(cx, |call, cx| call.share_project(project, cx))
378 .detach_and_log_err(cx);
379 }
380
381 fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
382 let active_call = ActiveCall::global(cx);
383 let project = self.project.clone();
384 active_call
385 .update(cx, |call, cx| call.unshare_project(project, cx))
386 .log_err();
387 }
388
389 pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
390 self.user_menu.update(cx, |user_menu, cx| {
391 let items = if let Some(_) = self.user_store.read(cx).current_user() {
392 vec![
393 ContextMenuItem::action("Settings", zed_actions::OpenSettings),
394 ContextMenuItem::action("Theme", theme_selector::Toggle),
395 ContextMenuItem::separator(),
396 ContextMenuItem::action(
397 "Share Feedback",
398 feedback::feedback_editor::GiveFeedback,
399 ),
400 ContextMenuItem::action("Sign Out", SignOut),
401 ]
402 } else {
403 vec![
404 ContextMenuItem::action("Settings", zed_actions::OpenSettings),
405 ContextMenuItem::action("Theme", theme_selector::Toggle),
406 ContextMenuItem::separator(),
407 ContextMenuItem::action(
408 "Share Feedback",
409 feedback::feedback_editor::GiveFeedback,
410 ),
411 ]
412 };
413 user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
414 });
415 }
416
417 fn render_branches_popover_host<'a>(
418 &'a self,
419 _theme: &'a theme::Titlebar,
420 cx: &'a mut ViewContext<Self>,
421 ) -> Option<AnyElement<Self>> {
422 self.branch_popover.as_ref().map(|child| {
423 let theme = theme::current(cx).clone();
424 let child = ChildView::new(child, cx);
425 let child = MouseEventHandler::new::<BranchList, _>(0, cx, |_, _| {
426 child
427 .flex(1., true)
428 .contained()
429 .constrained()
430 .with_width(theme.titlebar.menu.width)
431 .with_height(theme.titlebar.menu.height)
432 })
433 .on_click(MouseButton::Left, |_, _, _| {})
434 .on_down_out(MouseButton::Left, move |_, this, cx| {
435 this.branch_popover.take();
436 cx.emit(());
437 cx.notify();
438 })
439 .contained()
440 .into_any();
441
442 Overlay::new(child)
443 .with_fit_mode(OverlayFitMode::SwitchAnchor)
444 .with_anchor_corner(AnchorCorner::TopLeft)
445 .with_z_index(999)
446 .aligned()
447 .bottom()
448 .left()
449 .into_any()
450 })
451 }
452
453 fn render_project_popover_host<'a>(
454 &'a self,
455 _theme: &'a theme::Titlebar,
456 cx: &'a mut ViewContext<Self>,
457 ) -> Option<AnyElement<Self>> {
458 self.project_popover.as_ref().map(|child| {
459 let theme = theme::current(cx).clone();
460 let child = ChildView::new(child, cx);
461 let child = MouseEventHandler::new::<RecentProjects, _>(0, cx, |_, _| {
462 child
463 .flex(1., true)
464 .contained()
465 .constrained()
466 .with_width(theme.titlebar.menu.width)
467 .with_height(theme.titlebar.menu.height)
468 })
469 .on_click(MouseButton::Left, |_, _, _| {})
470 .on_down_out(MouseButton::Left, move |_, this, cx| {
471 this.project_popover.take();
472 cx.emit(());
473 cx.notify();
474 })
475 .into_any();
476
477 Overlay::new(child)
478 .with_fit_mode(OverlayFitMode::SwitchAnchor)
479 .with_anchor_corner(AnchorCorner::TopLeft)
480 .with_z_index(999)
481 .aligned()
482 .bottom()
483 .left()
484 .into_any()
485 })
486 }
487
488 pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
489 if self.branch_popover.take().is_none() {
490 if let Some(workspace) = self.workspace.upgrade(cx) {
491 let view = cx.add_view(|cx| build_branch_list(workspace, cx));
492 cx.subscribe(&view, |this, _, event, cx| {
493 match event {
494 PickerEvent::Dismiss => {
495 this.branch_popover = None;
496 }
497 }
498
499 cx.notify();
500 })
501 .detach();
502 self.project_popover.take();
503 cx.focus(&view);
504 self.branch_popover = Some(view);
505 }
506 }
507
508 cx.notify();
509 }
510
511 pub fn toggle_project_menu(&mut self, _: &ToggleProjectMenu, cx: &mut ViewContext<Self>) {
512 let workspace = self.workspace.clone();
513 if self.project_popover.take().is_none() {
514 cx.spawn(|this, mut cx| async move {
515 let workspaces = WORKSPACE_DB
516 .recent_workspaces_on_disk()
517 .await
518 .unwrap_or_default()
519 .into_iter()
520 .map(|(_, location)| location)
521 .collect();
522
523 let workspace = workspace.clone();
524 this.update(&mut cx, move |this, cx| {
525 let view = cx.add_view(|cx| build_recent_projects(workspace, workspaces, cx));
526
527 cx.subscribe(&view, |this, _, event, cx| {
528 match event {
529 PickerEvent::Dismiss => {
530 this.project_popover = None;
531 }
532 }
533
534 cx.notify();
535 })
536 .detach();
537 cx.focus(&view);
538 this.branch_popover.take();
539 this.project_popover = Some(view);
540 cx.notify();
541 })
542 .log_err();
543 })
544 .detach();
545 }
546 cx.notify();
547 }
548
549 fn render_toggle_screen_sharing_button(
550 &self,
551 theme: &Theme,
552 room: &ModelHandle<Room>,
553 cx: &mut ViewContext<Self>,
554 ) -> AnyElement<Self> {
555 let icon;
556 let tooltip;
557 if room.read(cx).is_screen_sharing() {
558 icon = "icons/desktop.svg";
559 tooltip = "Stop Sharing Screen"
560 } else {
561 icon = "icons/desktop.svg";
562 tooltip = "Share Screen";
563 }
564
565 let active = room.read(cx).is_screen_sharing();
566 let titlebar = &theme.titlebar;
567 MouseEventHandler::new::<ToggleScreenSharing, _>(0, cx, |state, _| {
568 let style = titlebar
569 .screen_share_button
570 .in_state(active)
571 .style_for(state);
572
573 Svg::new(icon)
574 .with_color(style.color)
575 .constrained()
576 .with_width(style.icon_width)
577 .aligned()
578 .constrained()
579 .with_width(style.button_width)
580 .with_height(style.button_width)
581 .contained()
582 .with_style(style.container)
583 })
584 .with_cursor_style(CursorStyle::PointingHand)
585 .on_click(MouseButton::Left, move |_, _, cx| {
586 toggle_screen_sharing(&Default::default(), cx)
587 })
588 .with_tooltip::<ToggleScreenSharing>(
589 0,
590 tooltip,
591 Some(Box::new(ToggleScreenSharing)),
592 theme.tooltip.clone(),
593 cx,
594 )
595 .aligned()
596 .into_any()
597 }
598 fn render_toggle_mute(
599 &self,
600 theme: &Theme,
601 room: &ModelHandle<Room>,
602 cx: &mut ViewContext<Self>,
603 ) -> AnyElement<Self> {
604 let icon;
605 let tooltip;
606 let is_muted = room.read(cx).is_muted(cx);
607 if is_muted {
608 icon = "icons/mic-mute.svg";
609 tooltip = "Unmute microphone";
610 } else {
611 icon = "icons/mic.svg";
612 tooltip = "Mute microphone";
613 }
614
615 let titlebar = &theme.titlebar;
616 MouseEventHandler::new::<ToggleMute, _>(0, cx, |state, _| {
617 let style = titlebar
618 .toggle_microphone_button
619 .in_state(is_muted)
620 .style_for(state);
621 let image = Svg::new(icon)
622 .with_color(style.color)
623 .constrained()
624 .with_width(style.icon_width)
625 .aligned()
626 .constrained()
627 .with_width(style.button_width)
628 .with_height(style.button_width)
629 .contained()
630 .with_style(style.container);
631 if let Some(color) = style.container.background_color {
632 image.with_background_color(color)
633 } else {
634 image
635 }
636 })
637 .with_cursor_style(CursorStyle::PointingHand)
638 .on_click(MouseButton::Left, move |_, _, cx| {
639 toggle_mute(&Default::default(), cx)
640 })
641 .with_tooltip::<ToggleMute>(
642 0,
643 tooltip,
644 Some(Box::new(ToggleMute)),
645 theme.tooltip.clone(),
646 cx,
647 )
648 .aligned()
649 .into_any()
650 }
651 fn render_toggle_deafen(
652 &self,
653 theme: &Theme,
654 room: &ModelHandle<Room>,
655 cx: &mut ViewContext<Self>,
656 ) -> AnyElement<Self> {
657 let icon;
658 let tooltip;
659 let is_deafened = room.read(cx).is_deafened().unwrap_or(false);
660 if is_deafened {
661 icon = "icons/speaker-off.svg";
662 tooltip = "Unmute speakers";
663 } else {
664 icon = "icons/speaker-loud.svg";
665 tooltip = "Mute speakers";
666 }
667
668 let titlebar = &theme.titlebar;
669 MouseEventHandler::new::<ToggleDeafen, _>(0, cx, |state, _| {
670 let style = titlebar
671 .toggle_speakers_button
672 .in_state(is_deafened)
673 .style_for(state);
674 Svg::new(icon)
675 .with_color(style.color)
676 .constrained()
677 .with_width(style.icon_width)
678 .aligned()
679 .constrained()
680 .with_width(style.button_width)
681 .with_height(style.button_width)
682 .contained()
683 .with_style(style.container)
684 })
685 .with_cursor_style(CursorStyle::PointingHand)
686 .on_click(MouseButton::Left, move |_, _, cx| {
687 toggle_deafen(&Default::default(), cx)
688 })
689 .with_tooltip::<ToggleDeafen>(
690 0,
691 tooltip,
692 Some(Box::new(ToggleDeafen)),
693 theme.tooltip.clone(),
694 cx,
695 )
696 .aligned()
697 .into_any()
698 }
699 fn render_leave_call(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
700 let icon = "icons/exit.svg";
701 let tooltip = "Leave call";
702
703 let titlebar = &theme.titlebar;
704 MouseEventHandler::new::<LeaveCall, _>(0, cx, |state, _| {
705 let style = titlebar.leave_call_button.style_for(state);
706 Svg::new(icon)
707 .with_color(style.color)
708 .constrained()
709 .with_width(style.icon_width)
710 .aligned()
711 .constrained()
712 .with_width(style.button_width)
713 .with_height(style.button_width)
714 .contained()
715 .with_style(style.container)
716 })
717 .with_cursor_style(CursorStyle::PointingHand)
718 .on_click(MouseButton::Left, move |_, _, cx| {
719 ActiveCall::global(cx)
720 .update(cx, |call, cx| call.hang_up(cx))
721 .detach_and_log_err(cx);
722 })
723 .with_tooltip::<LeaveCall>(
724 0,
725 tooltip,
726 Some(Box::new(LeaveCall)),
727 theme.tooltip.clone(),
728 cx,
729 )
730 .aligned()
731 .into_any()
732 }
733 fn render_in_call_share_unshare_button(
734 &self,
735 workspace: &ViewHandle<Workspace>,
736 theme: &Theme,
737 cx: &mut ViewContext<Self>,
738 ) -> Option<AnyElement<Self>> {
739 let project = workspace.read(cx).project();
740 if project.read(cx).is_remote() {
741 return None;
742 }
743
744 let is_shared = project.read(cx).is_shared();
745 let label = if is_shared { "Stop Sharing" } else { "Share" };
746 let tooltip = if is_shared {
747 "Stop sharing project with call participants"
748 } else {
749 "Share project with call participants"
750 };
751
752 let titlebar = &theme.titlebar;
753
754 enum ShareUnshare {}
755 Some(
756 Stack::new()
757 .with_child(
758 MouseEventHandler::new::<ShareUnshare, _>(0, cx, |state, _| {
759 //TODO: Ensure this button has consistent width for both text variations
760 let style = titlebar.share_button.inactive_state().style_for(state);
761 Label::new(label, style.text.clone())
762 .contained()
763 .with_style(style.container)
764 })
765 .with_cursor_style(CursorStyle::PointingHand)
766 .on_click(MouseButton::Left, move |_, this, cx| {
767 if is_shared {
768 this.unshare_project(&Default::default(), cx);
769 } else {
770 this.share_project(&Default::default(), cx);
771 }
772 })
773 .with_tooltip::<ShareUnshare>(
774 0,
775 tooltip.to_owned(),
776 None,
777 theme.tooltip.clone(),
778 cx,
779 ),
780 )
781 .aligned()
782 .contained()
783 .with_margin_left(theme.titlebar.item_spacing)
784 .into_any(),
785 )
786 }
787
788 fn render_user_menu_button(
789 &self,
790 theme: &Theme,
791 avatar: Option<Arc<ImageData>>,
792 cx: &mut ViewContext<Self>,
793 ) -> AnyElement<Self> {
794 let tooltip = theme.tooltip.clone();
795 let user_menu_button_style = if avatar.is_some() {
796 &theme.titlebar.user_menu.user_menu_button_online
797 } else {
798 &theme.titlebar.user_menu.user_menu_button_offline
799 };
800
801 let avatar_style = &user_menu_button_style.avatar;
802 Stack::new()
803 .with_child(
804 MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
805 let style = user_menu_button_style
806 .user_menu
807 .inactive_state()
808 .style_for(state);
809
810 let mut dropdown = Flex::row().align_children_center();
811
812 if let Some(avatar_img) = avatar {
813 dropdown = dropdown.with_child(Self::render_face(
814 avatar_img,
815 *avatar_style,
816 Color::transparent_black(),
817 None,
818 ));
819 };
820
821 dropdown
822 .with_child(
823 Svg::new("icons/caret_down.svg")
824 .with_color(user_menu_button_style.icon.color)
825 .constrained()
826 .with_width(user_menu_button_style.icon.width)
827 .contained()
828 .into_any(),
829 )
830 .aligned()
831 .constrained()
832 .with_height(style.width)
833 .contained()
834 .with_style(style.container)
835 .into_any()
836 })
837 .with_cursor_style(CursorStyle::PointingHand)
838 .on_down(MouseButton::Left, move |_, this, cx| {
839 this.user_menu.update(cx, |menu, _| menu.delay_cancel());
840 })
841 .on_click(MouseButton::Left, move |_, this, cx| {
842 this.toggle_user_menu(&Default::default(), cx)
843 })
844 .with_tooltip::<ToggleUserMenu>(
845 0,
846 "Toggle User Menu".to_owned(),
847 Some(Box::new(ToggleUserMenu)),
848 tooltip,
849 cx,
850 )
851 .contained(),
852 )
853 .with_child(
854 ChildView::new(&self.user_menu, cx)
855 .aligned()
856 .bottom()
857 .right(),
858 )
859 .into_any()
860 }
861
862 fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
863 let titlebar = &theme.titlebar;
864 MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
865 let style = titlebar.sign_in_button.inactive_state().style_for(state);
866 Label::new("Sign In", style.text.clone())
867 .contained()
868 .with_style(style.container)
869 })
870 .with_cursor_style(CursorStyle::PointingHand)
871 .on_click(MouseButton::Left, move |_, this, cx| {
872 let client = this.client.clone();
873 cx.app_context()
874 .spawn(|cx| async move { client.authenticate_and_connect(true, &cx).await })
875 .detach_and_log_err(cx);
876 })
877 .into_any()
878 }
879
880 fn render_collaborators(
881 &self,
882 workspace: &ViewHandle<Workspace>,
883 theme: &Theme,
884 room: &ModelHandle<Room>,
885 cx: &mut ViewContext<Self>,
886 ) -> Vec<Container<Self>> {
887 let mut participants = room
888 .read(cx)
889 .remote_participants()
890 .values()
891 .cloned()
892 .collect::<Vec<_>>();
893 participants.sort_by_cached_key(|p| p.user.github_login.clone());
894
895 participants
896 .into_iter()
897 .filter_map(|participant| {
898 let project = workspace.read(cx).project().read(cx);
899 let replica_id = project
900 .collaborators()
901 .get(&participant.peer_id)
902 .map(|collaborator| collaborator.replica_id);
903 let user = participant.user.clone();
904 Some(
905 Container::new(self.render_face_pile(
906 &user,
907 replica_id,
908 participant.peer_id,
909 Some(participant.location),
910 participant.muted,
911 participant.speaking,
912 workspace,
913 theme,
914 cx,
915 ))
916 .with_margin_right(theme.titlebar.face_pile_spacing),
917 )
918 })
919 .collect()
920 }
921
922 fn render_current_user(
923 &self,
924 workspace: &ViewHandle<Workspace>,
925 theme: &Theme,
926 user: &Arc<User>,
927 peer_id: PeerId,
928 muted: bool,
929 speaking: bool,
930 cx: &mut ViewContext<Self>,
931 ) -> AnyElement<Self> {
932 let replica_id = workspace.read(cx).project().read(cx).replica_id();
933
934 Container::new(self.render_face_pile(
935 user,
936 Some(replica_id),
937 peer_id,
938 None,
939 muted,
940 speaking,
941 workspace,
942 theme,
943 cx,
944 ))
945 .with_margin_right(theme.titlebar.item_spacing)
946 .into_any()
947 }
948
949 fn render_face_pile(
950 &self,
951 user: &User,
952 _replica_id: Option<ReplicaId>,
953 peer_id: PeerId,
954 location: Option<ParticipantLocation>,
955 muted: bool,
956 speaking: bool,
957 workspace: &ViewHandle<Workspace>,
958 theme: &Theme,
959 cx: &mut ViewContext<Self>,
960 ) -> AnyElement<Self> {
961 let user_id = user.id;
962 let project_id = workspace.read(cx).project().read(cx).remote_id();
963 let room = ActiveCall::global(cx).read(cx).room().cloned();
964 let self_peer_id = workspace.read(cx).client().peer_id();
965 let self_following = workspace.read(cx).is_being_followed(peer_id);
966 let self_following_initialized = self_following
967 && room.as_ref().map_or(false, |room| match project_id {
968 None => true,
969 Some(project_id) => room
970 .read(cx)
971 .followers_for(peer_id, project_id)
972 .iter()
973 .any(|&follower| Some(follower) == self_peer_id),
974 });
975
976 let leader_style = theme.titlebar.leader_avatar;
977 let follower_style = theme.titlebar.follower_avatar;
978
979 let microphone_state = if muted {
980 Some(theme.titlebar.muted)
981 } else if speaking {
982 Some(theme.titlebar.speaking)
983 } else {
984 None
985 };
986
987 let mut background_color = theme
988 .titlebar
989 .container
990 .background_color
991 .unwrap_or_default();
992
993 let participant_index = self
994 .user_store
995 .read(cx)
996 .participant_indices()
997 .get(&user_id)
998 .copied();
999 if let Some(participant_index) = participant_index {
1000 if self_following_initialized {
1001 let selection = theme
1002 .editor
1003 .selection_style_for_room_participant(participant_index.0)
1004 .selection;
1005 background_color = Color::blend(selection, background_color);
1006 background_color.a = 255;
1007 }
1008 }
1009
1010 enum TitlebarParticipant {}
1011
1012 let content = MouseEventHandler::new::<TitlebarParticipant, _>(
1013 peer_id.as_u64() as usize,
1014 cx,
1015 move |_, cx| {
1016 Stack::new()
1017 .with_children(user.avatar.as_ref().map(|avatar| {
1018 let face_pile = FacePile::new(theme.titlebar.follower_avatar_overlap)
1019 .with_child(Self::render_face(
1020 avatar.clone(),
1021 Self::location_style(workspace, location, leader_style, cx),
1022 background_color,
1023 microphone_state,
1024 ))
1025 .with_children(
1026 (|| {
1027 let project_id = project_id?;
1028 let room = room?.read(cx);
1029 let followers = room.followers_for(peer_id, project_id);
1030 Some(followers.into_iter().filter_map(|&follower| {
1031 if Some(follower) == self_peer_id {
1032 return None;
1033 }
1034 let participant =
1035 room.remote_participant_for_peer_id(follower)?;
1036 Some(Self::render_face(
1037 participant.user.avatar.clone()?,
1038 follower_style,
1039 background_color,
1040 None,
1041 ))
1042 }))
1043 })()
1044 .into_iter()
1045 .flatten(),
1046 )
1047 .with_children(
1048 self_following_initialized
1049 .then(|| self.user_store.read(cx).current_user())
1050 .and_then(|user| {
1051 Some(Self::render_face(
1052 user?.avatar.clone()?,
1053 follower_style,
1054 background_color,
1055 None,
1056 ))
1057 }),
1058 );
1059
1060 let mut container = face_pile
1061 .contained()
1062 .with_style(theme.titlebar.leader_selection);
1063
1064 if let Some(participant_index) = participant_index {
1065 if self_following_initialized {
1066 let color = theme
1067 .editor
1068 .selection_style_for_room_participant(participant_index.0)
1069 .selection;
1070 container = container.with_background_color(color);
1071 }
1072 }
1073
1074 container
1075 }))
1076 .with_children((|| {
1077 let participant_index = participant_index?;
1078 let color = theme
1079 .editor
1080 .selection_style_for_room_participant(participant_index.0)
1081 .cursor;
1082 Some(
1083 AvatarRibbon::new(color)
1084 .constrained()
1085 .with_width(theme.titlebar.avatar_ribbon.width)
1086 .with_height(theme.titlebar.avatar_ribbon.height)
1087 .aligned()
1088 .bottom(),
1089 )
1090 })())
1091 },
1092 );
1093
1094 if Some(peer_id) == self_peer_id {
1095 return content.into_any();
1096 }
1097
1098 content
1099 .with_cursor_style(CursorStyle::PointingHand)
1100 .on_click(MouseButton::Left, move |_, this, cx| {
1101 let Some(workspace) = this.workspace.upgrade(cx) else {
1102 return;
1103 };
1104 if let Some(task) =
1105 workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx))
1106 {
1107 task.detach_and_log_err(cx);
1108 }
1109 })
1110 .with_tooltip::<TitlebarParticipant>(
1111 peer_id.as_u64() as usize,
1112 format!("Follow {}", user.github_login),
1113 Some(Box::new(FollowNextCollaborator)),
1114 theme.tooltip.clone(),
1115 cx,
1116 )
1117 .into_any()
1118 }
1119
1120 fn location_style(
1121 workspace: &ViewHandle<Workspace>,
1122 location: Option<ParticipantLocation>,
1123 mut style: AvatarStyle,
1124 cx: &ViewContext<Self>,
1125 ) -> AvatarStyle {
1126 if let Some(location) = location {
1127 if let ParticipantLocation::SharedProject { project_id } = location {
1128 if Some(project_id) != workspace.read(cx).project().read(cx).remote_id() {
1129 style.image.grayscale = true;
1130 }
1131 } else {
1132 style.image.grayscale = true;
1133 }
1134 }
1135
1136 style
1137 }
1138
1139 fn render_face<V: 'static>(
1140 avatar: Arc<ImageData>,
1141 avatar_style: AvatarStyle,
1142 background_color: Color,
1143 microphone_state: Option<Color>,
1144 ) -> AnyElement<V> {
1145 Image::from_data(avatar)
1146 .with_style(avatar_style.image)
1147 .aligned()
1148 .contained()
1149 .with_background_color(microphone_state.unwrap_or(background_color))
1150 .with_corner_radius(avatar_style.outer_corner_radius)
1151 .constrained()
1152 .with_width(avatar_style.outer_width)
1153 .with_height(avatar_style.outer_width)
1154 .aligned()
1155 .into_any()
1156 }
1157
1158 fn render_connection_status(
1159 &self,
1160 status: &client::Status,
1161 cx: &mut ViewContext<Self>,
1162 ) -> Option<AnyElement<Self>> {
1163 enum ConnectionStatusButton {}
1164
1165 let theme = &theme::current(cx).clone();
1166 match status {
1167 client::Status::ConnectionError
1168 | client::Status::ConnectionLost
1169 | client::Status::Reauthenticating { .. }
1170 | client::Status::Reconnecting { .. }
1171 | client::Status::ReconnectionError { .. } => Some(
1172 Svg::new("icons/disconnected.svg")
1173 .with_color(theme.titlebar.offline_icon.color)
1174 .constrained()
1175 .with_width(theme.titlebar.offline_icon.width)
1176 .aligned()
1177 .contained()
1178 .with_style(theme.titlebar.offline_icon.container)
1179 .into_any(),
1180 ),
1181 client::Status::UpgradeRequired => {
1182 let auto_updater = auto_update::AutoUpdater::get(cx);
1183 let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
1184 Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
1185 Some(AutoUpdateStatus::Installing)
1186 | Some(AutoUpdateStatus::Downloading)
1187 | Some(AutoUpdateStatus::Checking) => "Updating...",
1188 Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
1189 "Please update Zed to Collaborate"
1190 }
1191 };
1192
1193 Some(
1194 MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
1195 Label::new(label, theme.titlebar.outdated_warning.text.clone())
1196 .contained()
1197 .with_style(theme.titlebar.outdated_warning.container)
1198 .aligned()
1199 })
1200 .with_cursor_style(CursorStyle::PointingHand)
1201 .on_click(MouseButton::Left, |_, _, cx| {
1202 if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
1203 if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
1204 workspace::restart(&Default::default(), cx);
1205 return;
1206 }
1207 }
1208 auto_update::check(&Default::default(), cx);
1209 })
1210 .into_any(),
1211 )
1212 }
1213 _ => None,
1214 }
1215 }
1216}
1217
1218pub struct AvatarRibbon {
1219 color: Color,
1220}
1221
1222impl AvatarRibbon {
1223 pub fn new(color: Color) -> AvatarRibbon {
1224 AvatarRibbon { color }
1225 }
1226}
1227
1228impl Element<CollabTitlebarItem> for AvatarRibbon {
1229 type LayoutState = ();
1230
1231 type PaintState = ();
1232
1233 fn layout(
1234 &mut self,
1235 constraint: gpui::SizeConstraint,
1236 _: &mut CollabTitlebarItem,
1237 _: &mut ViewContext<CollabTitlebarItem>,
1238 ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
1239 (constraint.max, ())
1240 }
1241
1242 fn paint(
1243 &mut self,
1244 bounds: RectF,
1245 _: RectF,
1246 _: &mut Self::LayoutState,
1247 _: &mut CollabTitlebarItem,
1248 cx: &mut ViewContext<CollabTitlebarItem>,
1249 ) -> Self::PaintState {
1250 let mut path = PathBuilder::new();
1251 path.reset(bounds.lower_left());
1252 path.curve_to(
1253 bounds.origin() + vec2f(bounds.height(), 0.),
1254 bounds.origin(),
1255 );
1256 path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
1257 path.curve_to(bounds.lower_right(), bounds.upper_right());
1258 path.line_to(bounds.lower_left());
1259 cx.scene().push_path(path.build(self.color, None));
1260 }
1261
1262 fn rect_for_text_range(
1263 &self,
1264 _: Range<usize>,
1265 _: RectF,
1266 _: RectF,
1267 _: &Self::LayoutState,
1268 _: &Self::PaintState,
1269 _: &CollabTitlebarItem,
1270 _: &ViewContext<CollabTitlebarItem>,
1271 ) -> Option<RectF> {
1272 None
1273 }
1274
1275 fn debug(
1276 &self,
1277 bounds: RectF,
1278 _: &Self::LayoutState,
1279 _: &Self::PaintState,
1280 _: &CollabTitlebarItem,
1281 _: &ViewContext<CollabTitlebarItem>,
1282 ) -> gpui::json::Value {
1283 json::json!({
1284 "type": "AvatarRibbon",
1285 "bounds": bounds.to_json(),
1286 "color": self.color.to_json(),
1287 })
1288 }
1289}