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