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