1use crate::face_pile::FacePile;
2use auto_update::AutoUpdateStatus;
3use call::{ActiveCall, ParticipantLocation, Room};
4use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore};
5use gpui::{
6 actions, canvas, div, point, px, rems, Action, AnyElement, AppContext, Element, Hsla,
7 InteractiveElement, IntoElement, Model, ParentElement, Path, Render,
8 StatefulInteractiveElement, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
9 WindowBounds,
10};
11use project::{Project, RepositoryEntry};
12use recent_projects::RecentProjects;
13use rpc::proto;
14use std::sync::Arc;
15use theme::{ActiveTheme, PlayerColors};
16use ui::{
17 h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
18 IconButton, IconName, TintColor, Tooltip,
19};
20use util::ResultExt;
21use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
22use workspace::{notifications::NotifyResultExt, Workspace};
23
24const MAX_PROJECT_NAME_LENGTH: usize = 40;
25const MAX_BRANCH_NAME_LENGTH: usize = 40;
26
27actions!(
28 collab,
29 [
30 ShareProject,
31 UnshareProject,
32 ToggleUserMenu,
33 ToggleProjectMenu,
34 SwitchBranch
35 ]
36);
37
38pub fn init(cx: &mut AppContext) {
39 cx.observe_new_views(|workspace: &mut Workspace, cx| {
40 let titlebar_item = cx.new_view(|cx| CollabTitlebarItem::new(workspace, cx));
41 workspace.set_titlebar_item(titlebar_item.into(), cx)
42 })
43 .detach();
44}
45
46pub struct CollabTitlebarItem {
47 project: Model<Project>,
48 user_store: Model<UserStore>,
49 client: Arc<Client>,
50 workspace: WeakView<Workspace>,
51 _subscriptions: Vec<Subscription>,
52}
53
54impl Render for CollabTitlebarItem {
55 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
56 let room = ActiveCall::global(cx).read(cx).room().cloned();
57 let current_user = self.user_store.read(cx).current_user();
58 let client = self.client.clone();
59 let project_id = self.project.read(cx).remote_id();
60
61 h_stack()
62 .id("titlebar")
63 .justify_between()
64 .w_full()
65 .h(rems(1.75))
66 // Set a non-scaling min-height here to ensure the titlebar is
67 // always at least the height of the traffic lights.
68 .min_h(px(32.))
69 .map(|this| {
70 if matches!(cx.window_bounds(), WindowBounds::Fullscreen) {
71 this.pl_2()
72 } else {
73 // Use pixels here instead of a rem-based size because the macOS traffic
74 // lights are a static size, and don't scale with the rest of the UI.
75 this.pl(px(80.))
76 }
77 })
78 .bg(cx.theme().colors().title_bar_background)
79 .on_click(|event, cx| {
80 if event.up.click_count == 2 {
81 cx.zoom_window();
82 }
83 })
84 // left side
85 .child(
86 h_stack()
87 .gap_1()
88 .children(self.render_project_host(cx))
89 .child(self.render_project_name(cx))
90 .child(div().pr_1().children(self.render_project_branch(cx)))
91 .when_some(
92 current_user.clone().zip(client.peer_id()).zip(room.clone()),
93 |this, ((current_user, peer_id), room)| {
94 let player_colors = cx.theme().players();
95 let room = room.read(cx);
96 let mut remote_participants =
97 room.remote_participants().values().collect::<Vec<_>>();
98 remote_participants.sort_by_key(|p| p.participant_index.0);
99
100 this.children(self.render_collaborator(
101 ¤t_user,
102 peer_id,
103 true,
104 room.is_speaking(),
105 room.is_muted(cx),
106 &room,
107 project_id,
108 ¤t_user,
109 cx,
110 ))
111 .children(
112 remote_participants.iter().filter_map(|collaborator| {
113 let is_present = project_id.map_or(false, |project_id| {
114 collaborator.location
115 == ParticipantLocation::SharedProject { project_id }
116 });
117
118 let face_pile = self.render_collaborator(
119 &collaborator.user,
120 collaborator.peer_id,
121 is_present,
122 collaborator.speaking,
123 collaborator.muted,
124 &room,
125 project_id,
126 ¤t_user,
127 cx,
128 )?;
129
130 Some(
131 v_stack()
132 .id(("collaborator", collaborator.user.id))
133 .child(face_pile)
134 .child(render_color_ribbon(
135 collaborator.participant_index,
136 player_colors,
137 ))
138 .cursor_pointer()
139 .on_click({
140 let peer_id = collaborator.peer_id;
141 cx.listener(move |this, _, cx| {
142 this.workspace
143 .update(cx, |workspace, cx| {
144 workspace.follow(peer_id, cx);
145 })
146 .ok();
147 })
148 })
149 .tooltip({
150 let login = collaborator.user.github_login.clone();
151 move |cx| {
152 Tooltip::text(format!("Follow {login}"), cx)
153 }
154 }),
155 )
156 }),
157 )
158 },
159 ),
160 )
161 // right side
162 .child(
163 h_stack()
164 .gap_1()
165 .pr_1()
166 .when_some(room, |this, room| {
167 let room = room.read(cx);
168 let project = self.project.read(cx);
169 let is_local = project.is_local();
170 let is_shared = is_local && project.is_shared();
171 let is_muted = room.is_muted(cx);
172 let is_deafened = room.is_deafened().unwrap_or(false);
173 let is_screen_sharing = room.is_screen_sharing();
174 let read_only = room.read_only();
175
176 this.when(is_local && !read_only, |this| {
177 this.child(
178 Button::new(
179 "toggle_sharing",
180 if is_shared { "Unshare" } else { "Share" },
181 )
182 .tooltip(move |cx| {
183 Tooltip::text(
184 if is_shared {
185 "Stop sharing project with call participants"
186 } else {
187 "Share project with call participants"
188 },
189 cx,
190 )
191 })
192 .style(ButtonStyle::Subtle)
193 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
194 .selected(is_shared)
195 .label_size(LabelSize::Small)
196 .on_click(cx.listener(
197 move |this, _, cx| {
198 if is_shared {
199 this.unshare_project(&Default::default(), cx);
200 } else {
201 this.share_project(&Default::default(), cx);
202 }
203 },
204 )),
205 )
206 })
207 .child(
208 div()
209 .child(
210 IconButton::new("leave-call", ui::IconName::Exit)
211 .style(ButtonStyle::Subtle)
212 .tooltip(|cx| Tooltip::text("Leave call", cx))
213 .icon_size(IconSize::Small)
214 .on_click(move |_, cx| {
215 ActiveCall::global(cx)
216 .update(cx, |call, cx| call.hang_up(cx))
217 .detach_and_log_err(cx);
218 }),
219 )
220 .pr_2(),
221 )
222 .when(!read_only, |this| {
223 this.child(
224 IconButton::new(
225 "mute-microphone",
226 if is_muted {
227 ui::IconName::MicMute
228 } else {
229 ui::IconName::Mic
230 },
231 )
232 .tooltip(move |cx| {
233 Tooltip::text(
234 if is_muted {
235 "Unmute microphone"
236 } else {
237 "Mute microphone"
238 },
239 cx,
240 )
241 })
242 .style(ButtonStyle::Subtle)
243 .icon_size(IconSize::Small)
244 .selected(is_muted)
245 .selected_style(ButtonStyle::Tinted(TintColor::Negative))
246 .on_click(move |_, cx| crate::toggle_mute(&Default::default(), cx)),
247 )
248 })
249 .child(
250 IconButton::new(
251 "mute-sound",
252 if is_deafened {
253 ui::IconName::AudioOff
254 } else {
255 ui::IconName::AudioOn
256 },
257 )
258 .style(ButtonStyle::Subtle)
259 .selected_style(ButtonStyle::Tinted(TintColor::Negative))
260 .icon_size(IconSize::Small)
261 .selected(is_deafened)
262 .tooltip(move |cx| {
263 if !read_only {
264 Tooltip::with_meta(
265 "Deafen Audio",
266 None,
267 "Mic will be muted",
268 cx,
269 )
270 } else {
271 Tooltip::text("Deafen Audio", cx)
272 }
273 })
274 .on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)),
275 )
276 .when(!read_only, |this| {
277 this.child(
278 IconButton::new("screen-share", ui::IconName::Screen)
279 .style(ButtonStyle::Subtle)
280 .icon_size(IconSize::Small)
281 .selected(is_screen_sharing)
282 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
283 .tooltip(move |cx| {
284 Tooltip::text(
285 if is_screen_sharing {
286 "Stop Sharing Screen"
287 } else {
288 "Share Screen"
289 },
290 cx,
291 )
292 })
293 .on_click(move |_, cx| {
294 crate::toggle_screen_sharing(&Default::default(), cx)
295 }),
296 )
297 })
298 .child(div().pr_2())
299 })
300 .map(|el| {
301 let status = self.client.status();
302 let status = &*status.borrow();
303 if matches!(status, client::Status::Connected { .. }) {
304 el.child(self.render_user_menu_button(cx))
305 } else {
306 el.children(self.render_connection_status(status, cx))
307 .child(self.render_sign_in_button(cx))
308 .child(self.render_user_menu_button(cx))
309 }
310 }),
311 )
312 }
313}
314
315fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas {
316 let color = colors.color_for_participant(participant_index.0).cursor;
317 canvas(move |bounds, cx| {
318 let height = bounds.size.height;
319 let horizontal_offset = height;
320 let vertical_offset = px(height.0 / 2.0);
321 let mut path = Path::new(bounds.lower_left());
322 path.curve_to(
323 bounds.origin + point(horizontal_offset, vertical_offset),
324 bounds.origin + point(px(0.0), vertical_offset),
325 );
326 path.line_to(bounds.upper_right() + point(-horizontal_offset, vertical_offset));
327 path.curve_to(
328 bounds.lower_right(),
329 bounds.upper_right() + point(px(0.0), vertical_offset),
330 );
331 path.line_to(bounds.lower_left());
332 cx.paint_path(path, color);
333 })
334 .h_1()
335 .w_full()
336}
337
338impl CollabTitlebarItem {
339 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
340 let project = workspace.project().clone();
341 let user_store = workspace.app_state().user_store.clone();
342 let client = workspace.app_state().client.clone();
343 let active_call = ActiveCall::global(cx);
344 let mut subscriptions = Vec::new();
345 subscriptions.push(
346 cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| {
347 cx.notify()
348 }),
349 );
350 subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
351 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
352 subscriptions.push(cx.observe_window_activation(Self::window_activation_changed));
353 subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
354
355 Self {
356 workspace: workspace.weak_handle(),
357 project,
358 user_store,
359 client,
360 _subscriptions: subscriptions,
361 }
362 }
363
364 // resolve if you are in a room -> render_project_owner
365 // render_project_owner -> resolve if you are in a room -> Option<foo>
366
367 pub fn render_project_host(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
368 let host = self.project.read(cx).host()?;
369 let host_user = self.user_store.read(cx).get_cached_user(host.user_id)?;
370 let participant_index = self
371 .user_store
372 .read(cx)
373 .participant_indices()
374 .get(&host_user.id)?;
375 Some(
376 Button::new("project_owner_trigger", host_user.github_login.clone())
377 .color(Color::Player(participant_index.0))
378 .style(ButtonStyle::Subtle)
379 .label_size(LabelSize::Small)
380 .tooltip(move |cx| {
381 Tooltip::text(
382 format!(
383 "{} is sharing this project. Click to follow.",
384 host_user.github_login.clone()
385 ),
386 cx,
387 )
388 })
389 .on_click({
390 let host_peer_id = host.peer_id.clone();
391 cx.listener(move |this, _, cx| {
392 this.workspace
393 .update(cx, |workspace, cx| {
394 workspace.follow(host_peer_id, cx);
395 })
396 .log_err();
397 })
398 }),
399 )
400 }
401
402 pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> impl Element {
403 let name = {
404 let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| {
405 let worktree = worktree.read(cx);
406 worktree.root_name()
407 });
408
409 names.next().unwrap_or("")
410 };
411
412 let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
413 let workspace = self.workspace.clone();
414 popover_menu("project_name_trigger")
415 .trigger(
416 Button::new("project_name_trigger", name)
417 .style(ButtonStyle::Subtle)
418 .label_size(LabelSize::Small)
419 .tooltip(move |cx| Tooltip::text("Recent Projects", cx)),
420 )
421 .menu(move |cx| Some(Self::render_project_popover(workspace.clone(), cx)))
422 }
423
424 pub fn render_project_branch(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
425 let entry = {
426 let mut names_and_branches =
427 self.project.read(cx).visible_worktrees(cx).map(|worktree| {
428 let worktree = worktree.read(cx);
429 worktree.root_git_entry()
430 });
431
432 names_and_branches.next().flatten()
433 };
434 let workspace = self.workspace.upgrade()?;
435 let branch_name = entry
436 .as_ref()
437 .and_then(RepositoryEntry::branch)
438 .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
439 Some(
440 popover_menu("project_branch_trigger")
441 .trigger(
442 Button::new("project_branch_trigger", branch_name)
443 .color(Color::Muted)
444 .style(ButtonStyle::Subtle)
445 .label_size(LabelSize::Small)
446 .tooltip(move |cx| {
447 Tooltip::with_meta(
448 "Recent Branches",
449 Some(&ToggleVcsMenu),
450 "Local branches only",
451 cx,
452 )
453 }),
454 )
455 .menu(move |cx| Self::render_vcs_popover(workspace.clone(), cx)),
456 )
457 }
458
459 fn render_collaborator(
460 &self,
461 user: &Arc<User>,
462 peer_id: PeerId,
463 is_present: bool,
464 is_speaking: bool,
465 is_muted: bool,
466 room: &Room,
467 project_id: Option<u64>,
468 current_user: &Arc<User>,
469 cx: &ViewContext<Self>,
470 ) -> Option<FacePile> {
471 if room.role_for_user(user.id) == Some(proto::ChannelRole::Guest) {
472 return None;
473 }
474
475 let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
476
477 let pile = FacePile::default()
478 .child(
479 Avatar::new(user.avatar_uri.clone())
480 .grayscale(!is_present)
481 .border_color(if is_speaking {
482 cx.theme().status().info_border
483 } else if is_muted {
484 cx.theme().status().error_border
485 } else {
486 Hsla::default()
487 }),
488 )
489 .children(followers.iter().filter_map(|follower_peer_id| {
490 let follower = room
491 .remote_participants()
492 .values()
493 .find_map(|p| (p.peer_id == *follower_peer_id).then_some(&p.user))
494 .or_else(|| {
495 (self.client.peer_id() == Some(*follower_peer_id)).then_some(current_user)
496 })?
497 .clone();
498
499 Some(Avatar::new(follower.avatar_uri.clone()))
500 }));
501
502 Some(pile)
503 }
504
505 fn window_activation_changed(&mut self, cx: &mut ViewContext<Self>) {
506 let project = if cx.is_window_active() {
507 Some(self.project.clone())
508 } else {
509 None
510 };
511 ActiveCall::global(cx)
512 .update(cx, |call, cx| call.set_location(project.as_ref(), cx))
513 .detach_and_log_err(cx);
514 }
515
516 fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
517 cx.notify();
518 }
519
520 fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
521 let active_call = ActiveCall::global(cx);
522 let project = self.project.clone();
523 active_call
524 .update(cx, |call, cx| call.share_project(project, cx))
525 .detach_and_log_err(cx);
526 }
527
528 fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
529 let active_call = ActiveCall::global(cx);
530 let project = self.project.clone();
531 active_call
532 .update(cx, |call, cx| call.unshare_project(project, cx))
533 .log_err();
534 }
535
536 pub fn render_vcs_popover(
537 workspace: View<Workspace>,
538 cx: &mut WindowContext<'_>,
539 ) -> Option<View<BranchList>> {
540 let view = build_branch_list(workspace, cx).log_err()?;
541 let focus_handle = view.focus_handle(cx);
542 cx.focus(&focus_handle);
543 Some(view)
544 }
545
546 pub fn render_project_popover(
547 workspace: WeakView<Workspace>,
548 cx: &mut WindowContext<'_>,
549 ) -> View<RecentProjects> {
550 let view = RecentProjects::open_popover(workspace, cx);
551
552 let focus_handle = view.focus_handle(cx);
553 cx.focus(&focus_handle);
554 view
555 }
556
557 fn render_connection_status(
558 &self,
559 status: &client::Status,
560 cx: &mut ViewContext<Self>,
561 ) -> Option<AnyElement> {
562 match status {
563 client::Status::ConnectionError
564 | client::Status::ConnectionLost
565 | client::Status::Reauthenticating { .. }
566 | client::Status::Reconnecting { .. }
567 | client::Status::ReconnectionError { .. } => Some(
568 div()
569 .id("disconnected")
570 .child(Icon::new(IconName::Disconnected).size(IconSize::Small))
571 .tooltip(|cx| Tooltip::text("Disconnected", cx))
572 .into_any_element(),
573 ),
574 client::Status::UpgradeRequired => {
575 let auto_updater = auto_update::AutoUpdater::get(cx);
576 let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
577 Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
578 Some(AutoUpdateStatus::Installing)
579 | Some(AutoUpdateStatus::Downloading)
580 | Some(AutoUpdateStatus::Checking) => "Updating...",
581 Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
582 "Please update Zed to Collaborate"
583 }
584 };
585
586 Some(
587 Button::new("connection-status", label)
588 .label_size(LabelSize::Small)
589 .on_click(|_, cx| {
590 if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
591 if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
592 workspace::restart(&Default::default(), cx);
593 return;
594 }
595 }
596 auto_update::check(&Default::default(), cx);
597 })
598 .into_any_element(),
599 )
600 }
601 _ => None,
602 }
603 }
604
605 pub fn render_sign_in_button(&mut self, _: &mut ViewContext<Self>) -> Button {
606 let client = self.client.clone();
607 Button::new("sign_in", "Sign in")
608 .label_size(LabelSize::Small)
609 .on_click(move |_, cx| {
610 let client = client.clone();
611 cx.spawn(move |mut cx| async move {
612 client
613 .authenticate_and_connect(true, &cx)
614 .await
615 .notify_async_err(&mut cx);
616 })
617 .detach();
618 })
619 }
620
621 pub fn render_user_menu_button(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
622 if let Some(user) = self.user_store.read(cx).current_user() {
623 popover_menu("user-menu")
624 .menu(|cx| {
625 ContextMenu::build(cx, |menu, _| {
626 menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
627 .action("Theme", theme_selector::Toggle.boxed_clone())
628 .separator()
629 .action("Share Feedback", feedback::GiveFeedback.boxed_clone())
630 .action("Sign Out", client::SignOut.boxed_clone())
631 })
632 .into()
633 })
634 .trigger(
635 ButtonLike::new("user-menu")
636 .child(
637 h_stack()
638 .gap_0p5()
639 .child(Avatar::new(user.avatar_uri.clone()))
640 .child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
641 )
642 .style(ButtonStyle::Subtle)
643 .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
644 )
645 .anchor(gpui::AnchorCorner::TopRight)
646 } else {
647 popover_menu("user-menu")
648 .menu(|cx| {
649 ContextMenu::build(cx, |menu, _| {
650 menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
651 .action("Theme", theme_selector::Toggle.boxed_clone())
652 .separator()
653 .action("Share Feedback", feedback::GiveFeedback.boxed_clone())
654 })
655 .into()
656 })
657 .trigger(
658 ButtonLike::new("user-menu")
659 .child(
660 h_stack()
661 .gap_0p5()
662 .child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
663 )
664 .style(ButtonStyle::Subtle)
665 .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)),
666 )
667 }
668 }
669}