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