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