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