1use crate::{contact_notification::ContactNotification, contacts_popover};
2use call::{ActiveCall, ParticipantLocation};
3use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
4use clock::ReplicaId;
5use contacts_popover::ContactsPopover;
6use gpui::{
7 actions,
8 color::Color,
9 elements::*,
10 geometry::{rect::RectF, vector::vec2f, PathBuilder},
11 json::{self, ToJson},
12 Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
13 Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
14};
15use settings::Settings;
16use std::ops::Range;
17use theme::Theme;
18use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
19
20actions!(collab, [ToggleCollaborationMenu, ShareProject]);
21
22pub fn init(cx: &mut MutableAppContext) {
23 cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
24 cx.add_action(CollabTitlebarItem::share_project);
25}
26
27pub struct CollabTitlebarItem {
28 workspace: WeakViewHandle<Workspace>,
29 user_store: ModelHandle<UserStore>,
30 contacts_popover: Option<ViewHandle<ContactsPopover>>,
31 _subscriptions: Vec<Subscription>,
32}
33
34impl Entity for CollabTitlebarItem {
35 type Event = ();
36}
37
38impl View for CollabTitlebarItem {
39 fn ui_name() -> &'static str {
40 "CollabTitlebarItem"
41 }
42
43 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
44 let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
45 workspace
46 } else {
47 return Empty::new().boxed();
48 };
49
50 let theme = cx.global::<Settings>().theme.clone();
51 let project = workspace.read(cx).project().read(cx);
52
53 let mut container = Flex::row();
54 if workspace.read(cx).client().status().borrow().is_connected() {
55 if project.is_shared()
56 || project.is_remote()
57 || ActiveCall::global(cx).read(cx).room().is_none()
58 {
59 container.add_child(self.render_toggle_contacts_button(&theme, cx));
60 } else {
61 container.add_child(self.render_share_button(&theme, cx));
62 }
63 }
64 container.add_children(self.render_collaborators(&workspace, &theme, cx));
65 container.add_children(self.render_current_user(&workspace, &theme, cx));
66 container.add_children(self.render_connection_status(&workspace, cx));
67 container.boxed()
68 }
69}
70
71impl CollabTitlebarItem {
72 pub fn new(
73 workspace: &ViewHandle<Workspace>,
74 user_store: &ModelHandle<UserStore>,
75 cx: &mut ViewContext<Self>,
76 ) -> Self {
77 let active_call = ActiveCall::global(cx);
78 let mut subscriptions = Vec::new();
79 subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
80 subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
81 subscriptions.push(cx.observe_window_activation(|this, active, cx| {
82 this.window_activation_changed(active, cx)
83 }));
84 subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
85 subscriptions.push(
86 cx.subscribe(user_store, move |this, user_store, event, cx| {
87 if let Some(workspace) = this.workspace.upgrade(cx) {
88 workspace.update(cx, |workspace, cx| {
89 if let client::Event::Contact { user, kind } = event {
90 if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
91 workspace.show_notification(user.id as usize, cx, |cx| {
92 cx.add_view(|cx| {
93 ContactNotification::new(
94 user.clone(),
95 *kind,
96 user_store,
97 cx,
98 )
99 })
100 })
101 }
102 }
103 });
104 }
105 }),
106 );
107
108 Self {
109 workspace: workspace.downgrade(),
110 user_store: user_store.clone(),
111 contacts_popover: None,
112 _subscriptions: subscriptions,
113 }
114 }
115
116 fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
117 let workspace = self.workspace.upgrade(cx);
118 let room = ActiveCall::global(cx).read(cx).room().cloned();
119 if let Some((workspace, room)) = workspace.zip(room) {
120 let workspace = workspace.read(cx);
121 let project = if active {
122 Some(workspace.project().clone())
123 } else {
124 None
125 };
126 room.update(cx, |room, cx| {
127 room.set_location(project.as_ref(), cx)
128 .detach_and_log_err(cx);
129 });
130 }
131 }
132
133 fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
134 if let Some(workspace) = self.workspace.upgrade(cx) {
135 let active_call = ActiveCall::global(cx);
136 let project = workspace.read(cx).project().clone();
137 active_call
138 .update(cx, |call, cx| call.share_project(project, cx))
139 .detach_and_log_err(cx);
140 }
141 }
142
143 pub fn toggle_contacts_popover(
144 &mut self,
145 _: &ToggleCollaborationMenu,
146 cx: &mut ViewContext<Self>,
147 ) {
148 match self.contacts_popover.take() {
149 Some(_) => {}
150 None => {
151 if let Some(workspace) = self.workspace.upgrade(cx) {
152 let project = workspace.read(cx).project().clone();
153 let user_store = workspace.read(cx).user_store().clone();
154 let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
155 cx.subscribe(&view, |this, _, event, cx| {
156 match event {
157 contacts_popover::Event::Dismissed => {
158 this.contacts_popover = None;
159 }
160 }
161
162 cx.notify();
163 })
164 .detach();
165 self.contacts_popover = Some(view);
166 }
167 }
168 }
169 cx.notify();
170 }
171
172 fn render_toggle_contacts_button(
173 &self,
174 theme: &Theme,
175 cx: &mut RenderContext<Self>,
176 ) -> ElementBox {
177 let titlebar = &theme.workspace.titlebar;
178 let badge = if self
179 .user_store
180 .read(cx)
181 .incoming_contact_requests()
182 .is_empty()
183 {
184 None
185 } else {
186 Some(
187 Empty::new()
188 .collapsed()
189 .contained()
190 .with_style(titlebar.toggle_contacts_badge)
191 .contained()
192 .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
193 .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
194 .aligned()
195 .boxed(),
196 )
197 };
198 Stack::new()
199 .with_child(
200 MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
201 let style = titlebar
202 .toggle_contacts_button
203 .style_for(state, self.contacts_popover.is_some());
204 Svg::new("icons/plus_8.svg")
205 .with_color(style.color)
206 .constrained()
207 .with_width(style.icon_width)
208 .aligned()
209 .constrained()
210 .with_width(style.button_width)
211 .with_height(style.button_width)
212 .contained()
213 .with_style(style.container)
214 .boxed()
215 })
216 .with_cursor_style(CursorStyle::PointingHand)
217 .on_click(MouseButton::Left, move |_, cx| {
218 cx.dispatch_action(ToggleCollaborationMenu);
219 })
220 .aligned()
221 .boxed(),
222 )
223 .with_children(badge)
224 .with_children(self.contacts_popover.as_ref().map(|popover| {
225 Overlay::new(
226 ChildView::new(popover, cx)
227 .contained()
228 .with_margin_top(titlebar.height)
229 .with_margin_left(titlebar.toggle_contacts_button.default.button_width)
230 .with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
231 .boxed(),
232 )
233 .with_fit_mode(OverlayFitMode::SwitchAnchor)
234 .with_anchor_corner(AnchorCorner::BottomLeft)
235 .boxed()
236 }))
237 .boxed()
238 }
239
240 fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
241 enum Share {}
242
243 let titlebar = &theme.workspace.titlebar;
244 MouseEventHandler::<Share>::new(0, cx, |state, _| {
245 let style = titlebar.share_button.style_for(state, false);
246 Label::new("Share".into(), style.text.clone())
247 .contained()
248 .with_style(style.container)
249 .boxed()
250 })
251 .with_cursor_style(CursorStyle::PointingHand)
252 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
253 .with_tooltip::<Share, _>(
254 0,
255 "Share project with call participants".into(),
256 None,
257 theme.tooltip.clone(),
258 cx,
259 )
260 .aligned()
261 .boxed()
262 }
263
264 fn render_collaborators(
265 &self,
266 workspace: &ViewHandle<Workspace>,
267 theme: &Theme,
268 cx: &mut RenderContext<Self>,
269 ) -> Vec<ElementBox> {
270 let active_call = ActiveCall::global(cx);
271 if let Some(room) = active_call.read(cx).room().cloned() {
272 let project = workspace.read(cx).project().read(cx);
273 let mut participants = room
274 .read(cx)
275 .remote_participants()
276 .iter()
277 .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
278 .collect::<Vec<_>>();
279 participants
280 .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
281 participants
282 .into_iter()
283 .filter_map(|(peer_id, participant)| {
284 let project = workspace.read(cx).project().read(cx);
285 let replica_id = project
286 .collaborators()
287 .get(&peer_id)
288 .map(|collaborator| collaborator.replica_id);
289 let user = participant.user.clone();
290 Some(self.render_avatar(
291 &user,
292 replica_id,
293 Some((peer_id, &user.github_login, participant.location)),
294 workspace,
295 theme,
296 cx,
297 ))
298 })
299 .collect()
300 } else {
301 Default::default()
302 }
303 }
304
305 fn render_current_user(
306 &self,
307 workspace: &ViewHandle<Workspace>,
308 theme: &Theme,
309 cx: &mut RenderContext<Self>,
310 ) -> Option<ElementBox> {
311 let user = workspace.read(cx).user_store().read(cx).current_user();
312 let replica_id = workspace.read(cx).project().read(cx).replica_id();
313 let status = *workspace.read(cx).client().status().borrow();
314 if let Some(user) = user {
315 Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
316 } else if matches!(status, client::Status::UpgradeRequired) {
317 None
318 } else {
319 Some(
320 MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
321 let style = theme
322 .workspace
323 .titlebar
324 .sign_in_prompt
325 .style_for(state, false);
326 Label::new("Sign in".to_string(), style.text.clone())
327 .contained()
328 .with_style(style.container)
329 .boxed()
330 })
331 .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
332 .with_cursor_style(CursorStyle::PointingHand)
333 .aligned()
334 .boxed(),
335 )
336 }
337 }
338
339 fn render_avatar(
340 &self,
341 user: &User,
342 replica_id: Option<ReplicaId>,
343 peer: Option<(PeerId, &str, ParticipantLocation)>,
344 workspace: &ViewHandle<Workspace>,
345 theme: &Theme,
346 cx: &mut RenderContext<Self>,
347 ) -> ElementBox {
348 let is_followed = peer.map_or(false, |(peer_id, _, _)| {
349 workspace.read(cx).is_following(peer_id)
350 });
351
352 let mut avatar_style;
353 if let Some((_, _, location)) = peer.as_ref() {
354 if let ParticipantLocation::SharedProject { project_id } = *location {
355 if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
356 avatar_style = theme.workspace.titlebar.avatar;
357 } else {
358 avatar_style = theme.workspace.titlebar.inactive_avatar;
359 }
360 } else {
361 avatar_style = theme.workspace.titlebar.inactive_avatar;
362 }
363 } else {
364 avatar_style = theme.workspace.titlebar.avatar;
365 }
366
367 let mut replica_color = None;
368 if let Some(replica_id) = replica_id {
369 let color = theme.editor.replica_selection_style(replica_id).cursor;
370 replica_color = Some(color);
371 if is_followed {
372 avatar_style.border = Border::all(1.0, color);
373 }
374 }
375
376 let content = Stack::new()
377 .with_children(user.avatar.as_ref().map(|avatar| {
378 Image::new(avatar.clone())
379 .with_style(avatar_style)
380 .constrained()
381 .with_width(theme.workspace.titlebar.avatar_width)
382 .aligned()
383 .boxed()
384 }))
385 .with_children(replica_color.map(|replica_color| {
386 AvatarRibbon::new(replica_color)
387 .constrained()
388 .with_width(theme.workspace.titlebar.avatar_ribbon.width)
389 .with_height(theme.workspace.titlebar.avatar_ribbon.height)
390 .aligned()
391 .bottom()
392 .boxed()
393 }))
394 .constrained()
395 .with_width(theme.workspace.titlebar.avatar_width)
396 .contained()
397 .with_margin_left(theme.workspace.titlebar.avatar_margin)
398 .boxed();
399
400 if let Some((peer_id, peer_github_login, location)) = peer {
401 if let Some(replica_id) = replica_id {
402 MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
403 .with_cursor_style(CursorStyle::PointingHand)
404 .on_click(MouseButton::Left, move |_, cx| {
405 cx.dispatch_action(ToggleFollow(peer_id))
406 })
407 .with_tooltip::<ToggleFollow, _>(
408 peer_id.0 as usize,
409 if is_followed {
410 format!("Unfollow {}", peer_github_login)
411 } else {
412 format!("Follow {}", peer_github_login)
413 },
414 Some(Box::new(FollowNextCollaborator)),
415 theme.tooltip.clone(),
416 cx,
417 )
418 .boxed()
419 } else if let ParticipantLocation::SharedProject { project_id } = location {
420 let user_id = user.id;
421 MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
422 .with_cursor_style(CursorStyle::PointingHand)
423 .on_click(MouseButton::Left, move |_, cx| {
424 cx.dispatch_action(JoinProject {
425 project_id,
426 follow_user_id: user_id,
427 })
428 })
429 .with_tooltip::<JoinProject, _>(
430 peer_id.0 as usize,
431 format!("Follow {} into external project", peer_github_login),
432 Some(Box::new(FollowNextCollaborator)),
433 theme.tooltip.clone(),
434 cx,
435 )
436 .boxed()
437 } else {
438 content
439 }
440 } else {
441 content
442 }
443 }
444
445 fn render_connection_status(
446 &self,
447 workspace: &ViewHandle<Workspace>,
448 cx: &mut RenderContext<Self>,
449 ) -> Option<ElementBox> {
450 let theme = &cx.global::<Settings>().theme;
451 match &*workspace.read(cx).client().status().borrow() {
452 client::Status::ConnectionError
453 | client::Status::ConnectionLost
454 | client::Status::Reauthenticating { .. }
455 | client::Status::Reconnecting { .. }
456 | client::Status::ReconnectionError { .. } => Some(
457 Container::new(
458 Align::new(
459 ConstrainedBox::new(
460 Svg::new("icons/cloud_slash_12.svg")
461 .with_color(theme.workspace.titlebar.offline_icon.color)
462 .boxed(),
463 )
464 .with_width(theme.workspace.titlebar.offline_icon.width)
465 .boxed(),
466 )
467 .boxed(),
468 )
469 .with_style(theme.workspace.titlebar.offline_icon.container)
470 .boxed(),
471 ),
472 client::Status::UpgradeRequired => Some(
473 Label::new(
474 "Please update Zed to collaborate".to_string(),
475 theme.workspace.titlebar.outdated_warning.text.clone(),
476 )
477 .contained()
478 .with_style(theme.workspace.titlebar.outdated_warning.container)
479 .aligned()
480 .boxed(),
481 ),
482 _ => None,
483 }
484 }
485}
486
487pub struct AvatarRibbon {
488 color: Color,
489}
490
491impl AvatarRibbon {
492 pub fn new(color: Color) -> AvatarRibbon {
493 AvatarRibbon { color }
494 }
495}
496
497impl Element for AvatarRibbon {
498 type LayoutState = ();
499
500 type PaintState = ();
501
502 fn layout(
503 &mut self,
504 constraint: gpui::SizeConstraint,
505 _: &mut gpui::LayoutContext,
506 ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
507 (constraint.max, ())
508 }
509
510 fn paint(
511 &mut self,
512 bounds: gpui::geometry::rect::RectF,
513 _: gpui::geometry::rect::RectF,
514 _: &mut Self::LayoutState,
515 cx: &mut gpui::PaintContext,
516 ) -> Self::PaintState {
517 let mut path = PathBuilder::new();
518 path.reset(bounds.lower_left());
519 path.curve_to(
520 bounds.origin() + vec2f(bounds.height(), 0.),
521 bounds.origin(),
522 );
523 path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
524 path.curve_to(bounds.lower_right(), bounds.upper_right());
525 path.line_to(bounds.lower_left());
526 cx.scene.push_path(path.build(self.color, None));
527 }
528
529 fn dispatch_event(
530 &mut self,
531 _: &gpui::Event,
532 _: RectF,
533 _: RectF,
534 _: &mut Self::LayoutState,
535 _: &mut Self::PaintState,
536 _: &mut gpui::EventContext,
537 ) -> bool {
538 false
539 }
540
541 fn rect_for_text_range(
542 &self,
543 _: Range<usize>,
544 _: RectF,
545 _: RectF,
546 _: &Self::LayoutState,
547 _: &Self::PaintState,
548 _: &gpui::MeasurementContext,
549 ) -> Option<RectF> {
550 None
551 }
552
553 fn debug(
554 &self,
555 bounds: gpui::geometry::rect::RectF,
556 _: &Self::LayoutState,
557 _: &Self::PaintState,
558 _: &gpui::DebugContext,
559 ) -> gpui::json::Value {
560 json::json!({
561 "type": "AvatarRibbon",
562 "bounds": bounds.to_json(),
563 "color": self.color.to_json(),
564 })
565 }
566}