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