title_bar.rs

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