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