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