title_bar.rs

  1mod application_menu;
  2mod collab;
  3mod onboarding_banner;
  4pub mod platform_title_bar;
  5mod platforms;
  6mod title_bar_settings;
  7
  8#[cfg(feature = "stories")]
  9mod stories;
 10
 11use crate::{
 12    application_menu::{ApplicationMenu, show_menus},
 13    platform_title_bar::PlatformTitleBar,
 14};
 15
 16#[cfg(not(target_os = "macos"))]
 17use crate::application_menu::{
 18    ActivateDirection, ActivateMenuLeft, ActivateMenuRight, OpenApplicationMenu,
 19};
 20
 21use auto_update::AutoUpdateStatus;
 22use call::ActiveCall;
 23use client::{Client, UserStore};
 24use gpui::{
 25    Action, AnyElement, App, Context, Corner, Element, Entity, InteractiveElement, IntoElement,
 26    MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription,
 27    WeakEntity, Window, actions, div,
 28};
 29use onboarding_banner::OnboardingBanner;
 30use project::Project;
 31use rpc::proto;
 32use settings::Settings as _;
 33use std::sync::Arc;
 34use theme::ActiveTheme;
 35use title_bar_settings::TitleBarSettings;
 36use ui::{
 37    Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, IconName, IconSize,
 38    IconWithIndicator, Indicator, PopoverMenu, Tooltip, h_flex, prelude::*,
 39};
 40use util::ResultExt;
 41use workspace::{Workspace, notifications::NotifyResultExt};
 42use zed_actions::{OpenRecent, OpenRemote};
 43
 44pub use onboarding_banner::restore_banner;
 45
 46#[cfg(feature = "stories")]
 47pub use stories::*;
 48
 49const MAX_PROJECT_NAME_LENGTH: usize = 40;
 50const MAX_BRANCH_NAME_LENGTH: usize = 40;
 51const MAX_SHORT_SHA_LENGTH: usize = 8;
 52
 53actions!(
 54    collab,
 55    [
 56        /// Toggles the user menu dropdown.
 57        ToggleUserMenu,
 58        /// Toggles the project menu dropdown.
 59        ToggleProjectMenu,
 60        /// Switches to a different git branch.
 61        SwitchBranch
 62    ]
 63);
 64
 65pub fn init(cx: &mut App) {
 66    TitleBarSettings::register(cx);
 67
 68    cx.observe_new(|workspace: &mut Workspace, window, cx| {
 69        let Some(window) = window else {
 70            return;
 71        };
 72        let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx));
 73        workspace.set_titlebar_item(item.into(), window, cx);
 74
 75        #[cfg(not(target_os = "macos"))]
 76        workspace.register_action(|workspace, action: &OpenApplicationMenu, window, cx| {
 77            if let Some(titlebar) = workspace
 78                .titlebar_item()
 79                .and_then(|item| item.downcast::<TitleBar>().ok())
 80            {
 81                titlebar.update(cx, |titlebar, cx| {
 82                    if let Some(ref menu) = titlebar.application_menu {
 83                        menu.update(cx, |menu, cx| menu.open_menu(action, window, cx));
 84                    }
 85                });
 86            }
 87        });
 88
 89        #[cfg(not(target_os = "macos"))]
 90        workspace.register_action(|workspace, _: &ActivateMenuRight, window, cx| {
 91            if let Some(titlebar) = workspace
 92                .titlebar_item()
 93                .and_then(|item| item.downcast::<TitleBar>().ok())
 94            {
 95                titlebar.update(cx, |titlebar, cx| {
 96                    if let Some(ref menu) = titlebar.application_menu {
 97                        menu.update(cx, |menu, cx| {
 98                            menu.navigate_menus_in_direction(ActivateDirection::Right, window, cx)
 99                        });
100                    }
101                });
102            }
103        });
104
105        #[cfg(not(target_os = "macos"))]
106        workspace.register_action(|workspace, _: &ActivateMenuLeft, window, cx| {
107            if let Some(titlebar) = workspace
108                .titlebar_item()
109                .and_then(|item| item.downcast::<TitleBar>().ok())
110            {
111                titlebar.update(cx, |titlebar, cx| {
112                    if let Some(ref menu) = titlebar.application_menu {
113                        menu.update(cx, |menu, cx| {
114                            menu.navigate_menus_in_direction(ActivateDirection::Left, window, cx)
115                        });
116                    }
117                });
118            }
119        });
120    })
121    .detach();
122}
123
124pub struct TitleBar {
125    platform_titlebar: Entity<PlatformTitleBar>,
126    project: Entity<Project>,
127    user_store: Entity<UserStore>,
128    client: Arc<Client>,
129    workspace: WeakEntity<Workspace>,
130    application_menu: Option<Entity<ApplicationMenu>>,
131    _subscriptions: Vec<Subscription>,
132    banner: Entity<OnboardingBanner>,
133}
134
135impl Render for TitleBar {
136    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
137        let title_bar_settings = *TitleBarSettings::get_global(cx);
138
139        let show_menus = show_menus(cx);
140
141        let mut children = Vec::new();
142
143        children.push(
144            h_flex()
145                .gap_1()
146                .map(|title_bar| {
147                    let mut render_project_items = title_bar_settings.show_branch_name
148                        || title_bar_settings.show_project_items;
149                    title_bar
150                        .when_some(
151                            self.application_menu.clone().filter(|_| !show_menus),
152                            |title_bar, menu| {
153                                render_project_items &=
154                                    !menu.update(cx, |menu, cx| menu.all_menus_shown(cx));
155                                title_bar.child(menu)
156                            },
157                        )
158                        .when(render_project_items, |title_bar| {
159                            title_bar
160                                .when(title_bar_settings.show_project_items, |title_bar| {
161                                    title_bar
162                                        .children(self.render_project_host(cx))
163                                        .child(self.render_project_name(cx))
164                                })
165                                .when(title_bar_settings.show_branch_name, |title_bar| {
166                                    title_bar.children(self.render_project_branch(cx))
167                                })
168                        })
169                })
170                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
171                .into_any_element(),
172        );
173
174        children.push(self.render_collaborator_list(window, cx).into_any_element());
175
176        if title_bar_settings.show_onboarding_banner {
177            children.push(self.banner.clone().into_any_element())
178        }
179
180        children.push(
181            h_flex()
182                .gap_1()
183                .pr_1()
184                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
185                .children(self.render_call_controls(window, cx))
186                .map(|el| {
187                    let status = self.client.status();
188                    let status = &*status.borrow();
189                    if matches!(status, client::Status::Connected { .. }) {
190                        el.child(self.render_user_menu_button(cx))
191                    } else {
192                        el.children(self.render_connection_status(status, cx))
193                            .when(TitleBarSettings::get_global(cx).show_sign_in, |el| {
194                                el.child(self.render_sign_in_button(cx))
195                            })
196                            .child(self.render_user_menu_button(cx))
197                    }
198                })
199                .into_any_element(),
200        );
201
202        if show_menus {
203            self.platform_titlebar.update(cx, |this, _| {
204                this.set_children(
205                    self.application_menu
206                        .clone()
207                        .map(|menu| menu.into_any_element()),
208                );
209            });
210
211            let height = PlatformTitleBar::height(window);
212            let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| {
213                platform_titlebar.title_bar_color(window, cx)
214            });
215
216            v_flex()
217                .w_full()
218                .child(self.platform_titlebar.clone().into_any_element())
219                .child(
220                    h_flex()
221                        .bg(title_bar_color)
222                        .h(height)
223                        .pl_2()
224                        .justify_between()
225                        .w_full()
226                        .children(children),
227                )
228                .into_any_element()
229        } else {
230            self.platform_titlebar.update(cx, |this, _| {
231                this.set_children(children);
232            });
233            self.platform_titlebar.clone().into_any_element()
234        }
235    }
236}
237
238impl TitleBar {
239    pub fn new(
240        id: impl Into<ElementId>,
241        workspace: &Workspace,
242        window: &mut Window,
243        cx: &mut Context<Self>,
244    ) -> Self {
245        let project = workspace.project().clone();
246        let user_store = workspace.app_state().user_store.clone();
247        let client = workspace.app_state().client.clone();
248        let active_call = ActiveCall::global(cx);
249
250        let platform_style = PlatformStyle::platform();
251        let application_menu = match platform_style {
252            PlatformStyle::Mac => {
253                if option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some() {
254                    Some(cx.new(|cx| ApplicationMenu::new(window, cx)))
255                } else {
256                    None
257                }
258            }
259            PlatformStyle::Linux | PlatformStyle::Windows => {
260                Some(cx.new(|cx| ApplicationMenu::new(window, cx)))
261            }
262        };
263
264        let mut subscriptions = Vec::new();
265        subscriptions.push(
266            cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| {
267                cx.notify()
268            }),
269        );
270        subscriptions.push(cx.subscribe(&project, |_, _, _: &project::Event, cx| cx.notify()));
271        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
272        subscriptions.push(cx.observe_window_activation(window, Self::window_activation_changed));
273        subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
274
275        let banner = cx.new(|cx| {
276            OnboardingBanner::new(
277                "Debugger Onboarding",
278                IconName::Debug,
279                "The Debugger",
280                None,
281                zed_actions::debugger::OpenOnboardingModal.boxed_clone(),
282                cx,
283            )
284        });
285
286        let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id));
287
288        Self {
289            platform_titlebar,
290            application_menu,
291            workspace: workspace.weak_handle(),
292            project,
293            user_store,
294            client,
295            _subscriptions: subscriptions,
296            banner,
297        }
298    }
299
300    fn render_ssh_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
301        let options = self.project.read(cx).ssh_connection_options(cx)?;
302        let host: SharedString = options.connection_string().into();
303
304        let nickname = options
305            .nickname
306            .clone()
307            .map(|nick| nick.into())
308            .unwrap_or_else(|| host.clone());
309
310        let (indicator_color, meta) = match self.project.read(cx).ssh_connection_state(cx)? {
311            remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")),
312            remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")),
313            remote::ConnectionState::HeartbeatMissed => (
314                Color::Warning,
315                format!("Connection attempt to {host} missed. Retrying..."),
316            ),
317            remote::ConnectionState::Reconnecting => (
318                Color::Warning,
319                format!("Lost connection to {host}. Reconnecting..."),
320            ),
321            remote::ConnectionState::Disconnected => {
322                (Color::Error, format!("Disconnected from {host}"))
323            }
324        };
325
326        let icon_color = match self.project.read(cx).ssh_connection_state(cx)? {
327            remote::ConnectionState::Connecting => Color::Info,
328            remote::ConnectionState::Connected => Color::Default,
329            remote::ConnectionState::HeartbeatMissed => Color::Warning,
330            remote::ConnectionState::Reconnecting => Color::Warning,
331            remote::ConnectionState::Disconnected => Color::Error,
332        };
333
334        let meta = SharedString::from(meta);
335
336        Some(
337            ButtonLike::new("ssh-server-icon")
338                .child(
339                    h_flex()
340                        .gap_2()
341                        .max_w_32()
342                        .child(
343                            IconWithIndicator::new(
344                                Icon::new(IconName::Server)
345                                    .size(IconSize::XSmall)
346                                    .color(icon_color),
347                                Some(Indicator::dot().color(indicator_color)),
348                            )
349                            .indicator_border_color(Some(cx.theme().colors().title_bar_background))
350                            .into_any_element(),
351                        )
352                        .child(
353                            Label::new(nickname.clone())
354                                .size(LabelSize::Small)
355                                .truncate(),
356                        ),
357                )
358                .tooltip(move |window, cx| {
359                    Tooltip::with_meta(
360                        "Remote Project",
361                        Some(&OpenRemote {
362                            from_existing_connection: false,
363                            create_new_window: false,
364                        }),
365                        meta.clone(),
366                        window,
367                        cx,
368                    )
369                })
370                .on_click(|_, window, cx| {
371                    window.dispatch_action(
372                        OpenRemote {
373                            from_existing_connection: false,
374                            create_new_window: false,
375                        }
376                        .boxed_clone(),
377                        cx,
378                    );
379                })
380                .into_any_element(),
381        )
382    }
383
384    pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
385        if self.project.read(cx).is_via_ssh() {
386            return self.render_ssh_project_host(cx);
387        }
388
389        if self.project.read(cx).is_disconnected(cx) {
390            return Some(
391                Button::new("disconnected", "Disconnected")
392                    .disabled(true)
393                    .color(Color::Disabled)
394                    .style(ButtonStyle::Subtle)
395                    .label_size(LabelSize::Small)
396                    .into_any_element(),
397            );
398        }
399
400        let host = self.project.read(cx).host()?;
401        let host_user = self.user_store.read(cx).get_cached_user(host.user_id)?;
402        let participant_index = self
403            .user_store
404            .read(cx)
405            .participant_indices()
406            .get(&host_user.id)?;
407        Some(
408            Button::new("project_owner_trigger", host_user.github_login.clone())
409                .color(Color::Player(participant_index.0))
410                .style(ButtonStyle::Subtle)
411                .label_size(LabelSize::Small)
412                .tooltip(Tooltip::text(format!(
413                    "{} is sharing this project. Click to follow.",
414                    host_user.github_login
415                )))
416                .on_click({
417                    let host_peer_id = host.peer_id;
418                    cx.listener(move |this, _, window, cx| {
419                        this.workspace
420                            .update(cx, |workspace, cx| {
421                                workspace.follow(host_peer_id, window, cx);
422                            })
423                            .log_err();
424                    })
425                })
426                .into_any_element(),
427        )
428    }
429
430    pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
431        let name = {
432            let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| {
433                let worktree = worktree.read(cx);
434                worktree.root_name()
435            });
436
437            names.next()
438        };
439        let is_project_selected = name.is_some();
440        let name = if let Some(name) = name {
441            util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH)
442        } else {
443            "Open recent project".to_string()
444        };
445
446        Button::new("project_name_trigger", name)
447            .when(!is_project_selected, |b| b.color(Color::Muted))
448            .style(ButtonStyle::Subtle)
449            .label_size(LabelSize::Small)
450            .tooltip(move |window, cx| {
451                Tooltip::for_action(
452                    "Recent Projects",
453                    &zed_actions::OpenRecent {
454                        create_new_window: false,
455                    },
456                    window,
457                    cx,
458                )
459            })
460            .on_click(cx.listener(move |_, _, window, cx| {
461                window.dispatch_action(
462                    OpenRecent {
463                        create_new_window: false,
464                    }
465                    .boxed_clone(),
466                    cx,
467                );
468            }))
469    }
470
471    pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
472        let repository = self.project.read(cx).active_repository(cx)?;
473        let workspace = self.workspace.upgrade()?;
474        let branch_name = {
475            let repo = repository.read(cx);
476            repo.branch
477                .as_ref()
478                .map(|branch| branch.name())
479                .map(|name| util::truncate_and_trailoff(&name, MAX_BRANCH_NAME_LENGTH))
480                .or_else(|| {
481                    repo.head_commit.as_ref().map(|commit| {
482                        commit
483                            .sha
484                            .chars()
485                            .take(MAX_SHORT_SHA_LENGTH)
486                            .collect::<String>()
487                    })
488                })
489        }?;
490
491        Some(
492            Button::new("project_branch_trigger", branch_name)
493                .color(Color::Muted)
494                .style(ButtonStyle::Subtle)
495                .label_size(LabelSize::Small)
496                .tooltip(move |window, cx| {
497                    Tooltip::with_meta(
498                        "Recent Branches",
499                        Some(&zed_actions::git::Branch),
500                        "Local branches only",
501                        window,
502                        cx,
503                    )
504                })
505                .on_click(move |_, window, cx| {
506                    let _ = workspace.update(cx, |_this, cx| {
507                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
508                    });
509                })
510                .when(
511                    TitleBarSettings::get_global(cx).show_branch_icon,
512                    |branch_button| {
513                        branch_button
514                            .icon(IconName::GitBranch)
515                            .icon_position(IconPosition::Start)
516                            .icon_color(Color::Muted)
517                            .icon_size(IconSize::Indicator)
518                    },
519                ),
520        )
521    }
522
523    fn window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
524        if window.is_window_active() {
525            ActiveCall::global(cx)
526                .update(cx, |call, cx| call.set_location(Some(&self.project), cx))
527                .detach_and_log_err(cx);
528        } else if cx.active_window().is_none() {
529            ActiveCall::global(cx)
530                .update(cx, |call, cx| call.set_location(None, cx))
531                .detach_and_log_err(cx);
532        }
533        self.workspace
534            .update(cx, |workspace, cx| {
535                workspace.update_active_view_for_followers(window, cx);
536            })
537            .ok();
538    }
539
540    fn active_call_changed(&mut self, cx: &mut Context<Self>) {
541        cx.notify();
542    }
543
544    fn share_project(&mut self, cx: &mut Context<Self>) {
545        let active_call = ActiveCall::global(cx);
546        let project = self.project.clone();
547        active_call
548            .update(cx, |call, cx| call.share_project(project, cx))
549            .detach_and_log_err(cx);
550    }
551
552    fn unshare_project(&mut self, _: &mut Window, cx: &mut Context<Self>) {
553        let active_call = ActiveCall::global(cx);
554        let project = self.project.clone();
555        active_call
556            .update(cx, |call, cx| call.unshare_project(project, cx))
557            .log_err();
558    }
559
560    fn render_connection_status(
561        &self,
562        status: &client::Status,
563        cx: &mut Context<Self>,
564    ) -> Option<AnyElement> {
565        match status {
566            client::Status::ConnectionError
567            | client::Status::ConnectionLost
568            | client::Status::Reauthenticating { .. }
569            | client::Status::Reconnecting { .. }
570            | client::Status::ReconnectionError { .. } => Some(
571                div()
572                    .id("disconnected")
573                    .child(Icon::new(IconName::Disconnected).size(IconSize::Small))
574                    .tooltip(Tooltip::text("Disconnected"))
575                    .into_any_element(),
576            ),
577            client::Status::UpgradeRequired => {
578                let auto_updater = auto_update::AutoUpdater::get(cx);
579                let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
580                    Some(AutoUpdateStatus::Updated { .. }) => "Please restart Zed to Collaborate",
581                    Some(AutoUpdateStatus::Installing { .. })
582                    | Some(AutoUpdateStatus::Downloading { .. })
583                    | Some(AutoUpdateStatus::Checking) => "Updating...",
584                    Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
585                        "Please update Zed to Collaborate"
586                    }
587                };
588
589                Some(
590                    Button::new("connection-status", label)
591                        .label_size(LabelSize::Small)
592                        .on_click(|_, window, cx| {
593                            if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
594                                if auto_updater.read(cx).status().is_updated() {
595                                    workspace::reload(&Default::default(), cx);
596                                    return;
597                                }
598                            }
599                            auto_update::check(&Default::default(), window, cx);
600                        })
601                        .into_any_element(),
602                )
603            }
604            _ => None,
605        }
606    }
607
608    pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
609        let client = self.client.clone();
610        Button::new("sign_in", "Sign in")
611            .label_size(LabelSize::Small)
612            .on_click(move |_, window, cx| {
613                let client = client.clone();
614                window
615                    .spawn(cx, async move |cx| {
616                        client
617                            .authenticate_and_connect(true, &cx)
618                            .await
619                            .into_response()
620                            .notify_async_err(cx);
621                    })
622                    .detach();
623            })
624    }
625
626    pub fn render_user_menu_button(&mut self, cx: &mut Context<Self>) -> impl Element {
627        let user_store = self.user_store.read(cx);
628        if let Some(user) = user_store.current_user() {
629            let has_subscription_period = self.user_store.read(cx).subscription_period().is_some();
630            let plan = self.user_store.read(cx).current_plan().filter(|_| {
631                // Since the user might be on the legacy free plan we filter based on whether we have a subscription period.
632                has_subscription_period
633            });
634            PopoverMenu::new("user-menu")
635                .anchor(Corner::TopRight)
636                .menu(move |window, cx| {
637                    ContextMenu::build(window, cx, |menu, _, _cx| {
638                        menu.link(
639                            format!(
640                                "Current Plan: {}",
641                                match plan {
642                                    None => "None",
643                                    Some(proto::Plan::Free) => "Zed Free",
644                                    Some(proto::Plan::ZedPro) => "Zed Pro",
645                                    Some(proto::Plan::ZedProTrial) => "Zed Pro (Trial)",
646                                }
647                            ),
648                            zed_actions::OpenAccountSettings.boxed_clone(),
649                        )
650                        .separator()
651                        .action("Settings", zed_actions::OpenSettings.boxed_clone())
652                        .action("Key Bindings", Box::new(zed_actions::OpenKeymap))
653                        .action(
654                            "Themes…",
655                            zed_actions::theme_selector::Toggle::default().boxed_clone(),
656                        )
657                        .action(
658                            "Icon Themes…",
659                            zed_actions::icon_theme_selector::Toggle::default().boxed_clone(),
660                        )
661                        .action(
662                            "Extensions",
663                            zed_actions::Extensions::default().boxed_clone(),
664                        )
665                        .separator()
666                        .action("Sign Out", client::SignOut.boxed_clone())
667                    })
668                    .into()
669                })
670                .trigger_with_tooltip(
671                    ButtonLike::new("user-menu")
672                        .child(
673                            h_flex()
674                                .gap_0p5()
675                                .children(
676                                    TitleBarSettings::get_global(cx)
677                                        .show_user_picture
678                                        .then(|| Avatar::new(user.avatar_uri.clone())),
679                                )
680                                .child(
681                                    Icon::new(IconName::ChevronDown)
682                                        .size(IconSize::Small)
683                                        .color(Color::Muted),
684                                ),
685                        )
686                        .style(ButtonStyle::Subtle),
687                    Tooltip::text("Toggle User Menu"),
688                )
689                .anchor(gpui::Corner::TopRight)
690        } else {
691            PopoverMenu::new("user-menu")
692                .anchor(Corner::TopRight)
693                .menu(|window, cx| {
694                    ContextMenu::build(window, cx, |menu, _, _| {
695                        menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
696                            .action("Key Bindings", Box::new(zed_actions::OpenKeymap))
697                            .action(
698                                "Themes…",
699                                zed_actions::theme_selector::Toggle::default().boxed_clone(),
700                            )
701                            .action(
702                                "Icon Themes…",
703                                zed_actions::icon_theme_selector::Toggle::default().boxed_clone(),
704                            )
705                            .action(
706                                "Extensions",
707                                zed_actions::Extensions::default().boxed_clone(),
708                            )
709                    })
710                    .into()
711                })
712                .trigger_with_tooltip(
713                    IconButton::new("user-menu", IconName::ChevronDown).icon_size(IconSize::Small),
714                    Tooltip::text("Toggle User Menu"),
715                )
716        }
717    }
718}