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