collab_titlebar_item.rs

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