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