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