1mod application_menu;
2pub mod collab;
3mod onboarding_banner;
4mod plan_chip;
5mod title_bar_settings;
6mod update_version;
7
8#[cfg(feature = "stories")]
9mod stories;
10
11use crate::application_menu::{ApplicationMenu, show_menus};
12use crate::plan_chip::PlanChip;
13pub use platform_title_bar::{
14 self, DraggedWindowTab, MergeAllWindows, MoveTabToNewWindow, PlatformTitleBar,
15 ShowNextWindowTab, ShowPreviousWindowTab,
16};
17use project::linked_worktree_short_name;
18
19#[cfg(not(target_os = "macos"))]
20use crate::application_menu::{
21 ActivateDirection, ActivateMenuLeft, ActivateMenuRight, OpenApplicationMenu,
22};
23
24use auto_update::AutoUpdateStatus;
25use call::ActiveCall;
26use client::{Client, UserStore, zed_urls};
27use cloud_api_types::Plan;
28
29use gpui::{
30 Action, Animation, AnimationExt, AnyElement, App, Context, Corner, Element, Entity, Focusable,
31 InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
32 StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
33 pulsating_between,
34};
35use onboarding_banner::OnboardingBanner;
36use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
37use remote::RemoteConnectionOptions;
38use settings::Settings;
39
40use std::sync::Arc;
41use std::time::Duration;
42use theme::ActiveTheme;
43use title_bar_settings::TitleBarSettings;
44use ui::{
45 Avatar, ButtonLike, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle,
46 TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
47};
48use update_version::UpdateVersion;
49use util::ResultExt;
50use workspace::{
51 MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
52};
53
54use zed_actions::OpenRemote;
55
56pub use onboarding_banner::restore_banner;
57
58#[cfg(feature = "stories")]
59pub use stories::*;
60
61const MAX_PROJECT_NAME_LENGTH: usize = 40;
62const MAX_BRANCH_NAME_LENGTH: usize = 40;
63const MAX_SHORT_SHA_LENGTH: usize = 8;
64
65actions!(
66 collab,
67 [
68 /// Toggles the user menu dropdown.
69 ToggleUserMenu,
70 /// Toggles the project menu dropdown.
71 ToggleProjectMenu,
72 /// Switches to a different git branch.
73 SwitchBranch,
74 /// A debug action to simulate an update being available to test the update banner UI.
75 SimulateUpdateAvailable
76 ]
77);
78
79pub fn init(cx: &mut App) {
80 platform_title_bar::PlatformTitleBar::init(cx);
81
82 cx.observe_new(|workspace: &mut Workspace, window, cx| {
83 let Some(window) = window else {
84 return;
85 };
86 let multi_workspace = workspace.multi_workspace().cloned();
87 let item = cx.new(|cx| TitleBar::new("title-bar", workspace, multi_workspace, window, cx));
88 workspace.set_titlebar_item(item.into(), window, cx);
89
90 workspace.register_action(|workspace, _: &SimulateUpdateAvailable, _window, cx| {
91 if let Some(titlebar) = workspace
92 .titlebar_item()
93 .and_then(|item| item.downcast::<TitleBar>().ok())
94 {
95 titlebar.update(cx, |titlebar, cx| {
96 titlebar.toggle_update_simulation(cx);
97 });
98 }
99 });
100
101 #[cfg(not(target_os = "macos"))]
102 workspace.register_action(|workspace, action: &OpenApplicationMenu, window, cx| {
103 if let Some(titlebar) = workspace
104 .titlebar_item()
105 .and_then(|item| item.downcast::<TitleBar>().ok())
106 {
107 titlebar.update(cx, |titlebar, cx| {
108 if let Some(ref menu) = titlebar.application_menu {
109 menu.update(cx, |menu, cx| menu.open_menu(action, window, cx));
110 }
111 });
112 }
113 });
114
115 #[cfg(not(target_os = "macos"))]
116 workspace.register_action(|workspace, _: &ActivateMenuRight, window, cx| {
117 if let Some(titlebar) = workspace
118 .titlebar_item()
119 .and_then(|item| item.downcast::<TitleBar>().ok())
120 {
121 titlebar.update(cx, |titlebar, cx| {
122 if let Some(ref menu) = titlebar.application_menu {
123 menu.update(cx, |menu, cx| {
124 menu.navigate_menus_in_direction(ActivateDirection::Right, window, cx)
125 });
126 }
127 });
128 }
129 });
130
131 #[cfg(not(target_os = "macos"))]
132 workspace.register_action(|workspace, _: &ActivateMenuLeft, window, cx| {
133 if let Some(titlebar) = workspace
134 .titlebar_item()
135 .and_then(|item| item.downcast::<TitleBar>().ok())
136 {
137 titlebar.update(cx, |titlebar, cx| {
138 if let Some(ref menu) = titlebar.application_menu {
139 menu.update(cx, |menu, cx| {
140 menu.navigate_menus_in_direction(ActivateDirection::Left, window, cx)
141 });
142 }
143 });
144 }
145 });
146 })
147 .detach();
148}
149
150pub struct TitleBar {
151 platform_titlebar: Entity<PlatformTitleBar>,
152 project: Entity<Project>,
153 user_store: Entity<UserStore>,
154 client: Arc<Client>,
155 workspace: WeakEntity<Workspace>,
156 multi_workspace: Option<WeakEntity<MultiWorkspace>>,
157 application_menu: Option<Entity<ApplicationMenu>>,
158 _subscriptions: Vec<Subscription>,
159 banner: Option<Entity<OnboardingBanner>>,
160 update_version: Entity<UpdateVersion>,
161 screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
162 _diagnostics_subscription: Option<gpui::Subscription>,
163}
164
165impl Render for TitleBar {
166 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
167 if self.multi_workspace.is_none() {
168 if let Some(mw) = self
169 .workspace
170 .upgrade()
171 .and_then(|ws| ws.read(cx).multi_workspace().cloned())
172 {
173 self.multi_workspace = Some(mw.clone());
174 self.platform_titlebar.update(cx, |titlebar, _cx| {
175 titlebar.set_multi_workspace(mw);
176 });
177 }
178 }
179
180 let title_bar_settings = *TitleBarSettings::get_global(cx);
181 let button_layout = title_bar_settings.button_layout;
182
183 let show_menus = show_menus(cx);
184
185 let mut children = Vec::new();
186
187 let mut project_name = None;
188 let mut repository = None;
189 let mut linked_worktree_name = None;
190 if let Some(worktree) = self.effective_active_worktree(cx) {
191 repository = self.get_repository_for_worktree(&worktree, cx);
192 let worktree = worktree.read(cx);
193 project_name = worktree
194 .root_name()
195 .file_name()
196 .map(|name| SharedString::from(name.to_string()));
197 linked_worktree_name = repository.as_ref().and_then(|repo| {
198 let repo = repo.read(cx);
199 linked_worktree_short_name(
200 repo.original_repo_abs_path.as_ref(),
201 repo.work_directory_abs_path.as_ref(),
202 )
203 .filter(|name| Some(name) != project_name.as_ref())
204 });
205 }
206
207 children.push(
208 h_flex()
209 .h_full()
210 .gap_0p5()
211 .map(|title_bar| {
212 let mut render_project_items = title_bar_settings.show_branch_name
213 || title_bar_settings.show_project_items;
214 title_bar
215 .when_some(
216 self.application_menu.clone().filter(|_| !show_menus),
217 |title_bar, menu| {
218 render_project_items &=
219 !menu.update(cx, |menu, cx| menu.all_menus_shown(cx));
220 title_bar.child(menu)
221 },
222 )
223 .children(self.render_restricted_mode(cx))
224 .when(render_project_items, |title_bar| {
225 title_bar
226 .when(title_bar_settings.show_project_items, |title_bar| {
227 title_bar
228 .children(self.render_project_host(cx))
229 .child(self.render_project_name(project_name, window, cx))
230 })
231 .when_some(
232 repository.filter(|_| title_bar_settings.show_branch_name),
233 |title_bar, repository| {
234 title_bar.children(self.render_project_branch(
235 repository,
236 linked_worktree_name,
237 cx,
238 ))
239 },
240 )
241 })
242 })
243 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
244 .into_any_element(),
245 );
246
247 children.push(self.render_collaborator_list(window, cx).into_any_element());
248
249 if title_bar_settings.show_onboarding_banner {
250 if let Some(banner) = &self.banner {
251 children.push(banner.clone().into_any_element())
252 }
253 }
254
255 let status = self.client.status();
256 let status = &*status.borrow();
257 let user = self.user_store.read(cx).current_user();
258
259 let signed_in = user.is_some();
260 let is_signing_in = user.is_none()
261 && matches!(
262 status,
263 client::Status::Authenticating
264 | client::Status::Authenticated
265 | client::Status::Connecting
266 );
267 let is_signed_out_or_auth_error = user.is_none()
268 && matches!(
269 status,
270 client::Status::SignedOut | client::Status::AuthenticationError
271 );
272
273 children.push(
274 h_flex()
275 .map(|this| {
276 if signed_in {
277 this.pr_1p5()
278 } else {
279 this.pr_1()
280 }
281 })
282 .gap_1()
283 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
284 .children(self.render_call_controls(window, cx))
285 .children(self.render_connection_status(status, cx))
286 .child(self.update_version.clone())
287 .when(
288 user.is_none()
289 && is_signed_out_or_auth_error
290 && TitleBarSettings::get_global(cx).show_sign_in,
291 |this| this.child(self.render_sign_in_button(cx)),
292 )
293 .when(is_signing_in, |this| {
294 this.child(
295 Label::new("Signing in…")
296 .size(LabelSize::Small)
297 .color(Color::Muted)
298 .with_animation(
299 "signing-in",
300 Animation::new(Duration::from_secs(2))
301 .repeat()
302 .with_easing(pulsating_between(0.4, 0.8)),
303 |label, delta| label.alpha(delta),
304 ),
305 )
306 })
307 .when(TitleBarSettings::get_global(cx).show_user_menu, |this| {
308 this.child(self.render_user_menu_button(cx))
309 })
310 .into_any_element(),
311 );
312
313 if show_menus {
314 self.platform_titlebar.update(cx, |this, _| {
315 this.set_button_layout(button_layout);
316 this.set_children(
317 self.application_menu
318 .clone()
319 .map(|menu| menu.into_any_element()),
320 );
321 });
322
323 let height = platform_title_bar_height(window);
324 let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| {
325 platform_titlebar.title_bar_color(window, cx)
326 });
327
328 v_flex()
329 .w_full()
330 .child(self.platform_titlebar.clone().into_any_element())
331 .child(
332 h_flex()
333 .bg(title_bar_color)
334 .h(height)
335 .pl_2()
336 .justify_between()
337 .w_full()
338 .children(children),
339 )
340 .into_any_element()
341 } else {
342 self.platform_titlebar.update(cx, |this, _| {
343 this.set_button_layout(button_layout);
344 this.set_children(children);
345 });
346 self.platform_titlebar.clone().into_any_element()
347 }
348 }
349}
350
351impl TitleBar {
352 pub fn new(
353 id: impl Into<ElementId>,
354 workspace: &Workspace,
355 multi_workspace: Option<WeakEntity<MultiWorkspace>>,
356 window: &mut Window,
357 cx: &mut Context<Self>,
358 ) -> Self {
359 let project = workspace.project().clone();
360 let git_store = project.read(cx).git_store().clone();
361 let user_store = workspace.app_state().user_store.clone();
362 let client = workspace.app_state().client.clone();
363 let active_call = ActiveCall::global(cx);
364
365 let platform_style = PlatformStyle::platform();
366 let application_menu = match platform_style {
367 PlatformStyle::Mac => {
368 if option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some() {
369 Some(cx.new(|cx| ApplicationMenu::new(window, cx)))
370 } else {
371 None
372 }
373 }
374 PlatformStyle::Linux | PlatformStyle::Windows => {
375 Some(cx.new(|cx| ApplicationMenu::new(window, cx)))
376 }
377 };
378
379 let mut subscriptions = Vec::new();
380 subscriptions.push(
381 cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| {
382 cx.notify()
383 }),
384 );
385
386 subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
387 subscriptions.push(cx.observe_window_activation(window, Self::window_activation_changed));
388 subscriptions.push(
389 cx.subscribe(&git_store, move |_, _, event, cx| match event {
390 GitStoreEvent::ActiveRepositoryChanged(_)
391 | GitStoreEvent::RepositoryUpdated(_, _, true) => {
392 cx.notify();
393 }
394 _ => {}
395 }),
396 );
397 subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify()));
398 subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify()));
399 if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
400 subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| {
401 cx.notify();
402 }));
403 }
404
405 let update_version = cx.new(|cx| UpdateVersion::new(cx));
406 let platform_titlebar = cx.new(|cx| {
407 let mut titlebar = PlatformTitleBar::new(id, cx);
408 if let Some(mw) = multi_workspace.clone() {
409 titlebar = titlebar.with_multi_workspace(mw);
410 }
411 titlebar
412 });
413
414 let mut this = Self {
415 platform_titlebar,
416 application_menu,
417 workspace: workspace.weak_handle(),
418 multi_workspace,
419 project,
420 user_store,
421 client,
422 _subscriptions: subscriptions,
423 banner: None,
424 update_version,
425 screen_share_popover_handle: PopoverMenuHandle::default(),
426 _diagnostics_subscription: None,
427 };
428
429 this.observe_diagnostics(cx);
430
431 this
432 }
433
434 fn worktree_count(&self, cx: &App) -> usize {
435 self.project.read(cx).visible_worktrees(cx).count()
436 }
437
438 fn toggle_update_simulation(&mut self, cx: &mut Context<Self>) {
439 self.update_version
440 .update(cx, |banner, cx| banner.update_simulation(cx));
441 cx.notify();
442 }
443
444 /// Returns the worktree to display in the title bar.
445 /// - Prefer the worktree owning the project's active repository
446 /// - Fall back to the first visible worktree
447 pub fn effective_active_worktree(&self, cx: &App) -> Option<Entity<project::Worktree>> {
448 let project = self.project.read(cx);
449
450 if let Some(repo) = project.active_repository(cx) {
451 let repo = repo.read(cx);
452 let repo_path = &repo.work_directory_abs_path;
453
454 for worktree in project.visible_worktrees(cx) {
455 let worktree_path = worktree.read(cx).abs_path();
456 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
457 return Some(worktree);
458 }
459 }
460 }
461
462 project.visible_worktrees(cx).next()
463 }
464
465 fn get_repository_for_worktree(
466 &self,
467 worktree: &Entity<project::Worktree>,
468 cx: &App,
469 ) -> Option<Entity<project::git_store::Repository>> {
470 let project = self.project.read(cx);
471 let git_store = project.git_store().read(cx);
472 let worktree_path = worktree.read(cx).abs_path();
473
474 git_store
475 .repositories()
476 .values()
477 .filter(|repo| {
478 let repo_path = &repo.read(cx).work_directory_abs_path;
479 worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref())
480 })
481 .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
482 .cloned()
483 }
484
485 fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
486 let workspace = self.workspace.clone();
487
488 let options = self.project.read(cx).remote_connection_options(cx)?;
489 let host: SharedString = options.display_name().into();
490
491 let (nickname, tooltip_title, icon) = match options {
492 RemoteConnectionOptions::Ssh(options) => (
493 options.nickname.map(|nick| nick.into()),
494 "Remote Project",
495 IconName::Server,
496 ),
497 RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux),
498 RemoteConnectionOptions::Docker(_dev_container_connection) => {
499 (None, "Dev Container", IconName::Box)
500 }
501 #[cfg(any(test, feature = "test-support"))]
502 RemoteConnectionOptions::Mock(_) => (None, "Mock Remote Project", IconName::Server),
503 };
504
505 let nickname = nickname.unwrap_or_else(|| host.clone());
506
507 let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
508 remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")),
509 remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")),
510 remote::ConnectionState::HeartbeatMissed => (
511 Color::Warning,
512 format!("Connection attempt to {host} missed. Retrying..."),
513 ),
514 remote::ConnectionState::Reconnecting => (
515 Color::Warning,
516 format!("Lost connection to {host}. Reconnecting..."),
517 ),
518 remote::ConnectionState::Disconnected => {
519 (Color::Error, format!("Disconnected from {host}"))
520 }
521 };
522
523 let icon_color = match self.project.read(cx).remote_connection_state(cx)? {
524 remote::ConnectionState::Connecting => Color::Info,
525 remote::ConnectionState::Connected => Color::Default,
526 remote::ConnectionState::HeartbeatMissed => Color::Warning,
527 remote::ConnectionState::Reconnecting => Color::Warning,
528 remote::ConnectionState::Disconnected => Color::Error,
529 };
530
531 let meta = SharedString::from(meta);
532
533 Some(
534 PopoverMenu::new("remote-project-menu")
535 .menu(move |window, cx| {
536 let workspace_entity = workspace.upgrade()?;
537 let fs = workspace_entity.read(cx).project().read(cx).fs().clone();
538 Some(recent_projects::RemoteServerProjects::popover(
539 fs,
540 workspace.clone(),
541 false,
542 window,
543 cx,
544 ))
545 })
546 .trigger_with_tooltip(
547 ButtonLike::new("remote_project")
548 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
549 .child(
550 h_flex()
551 .gap_2()
552 .max_w_32()
553 .child(
554 IconWithIndicator::new(
555 Icon::new(icon).size(IconSize::Small).color(icon_color),
556 Some(Indicator::dot().color(indicator_color)),
557 )
558 .indicator_border_color(Some(
559 cx.theme().colors().title_bar_background,
560 ))
561 .into_any_element(),
562 )
563 .child(Label::new(nickname).size(LabelSize::Small).truncate()),
564 ),
565 move |_window, cx| {
566 Tooltip::with_meta(
567 tooltip_title,
568 Some(&OpenRemote {
569 from_existing_connection: false,
570 create_new_window: false,
571 }),
572 meta.clone(),
573 cx,
574 )
575 },
576 )
577 .anchor(gpui::Corner::TopLeft)
578 .into_any_element(),
579 )
580 }
581
582 pub fn render_restricted_mode(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
583 let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
584 .map(|trusted_worktrees| {
585 trusted_worktrees
586 .read(cx)
587 .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx)
588 })
589 .unwrap_or(false);
590 if !has_restricted_worktrees {
591 return None;
592 }
593
594 let button = Button::new("restricted_mode_trigger", "Restricted Mode")
595 .style(ButtonStyle::Tinted(TintColor::Warning))
596 .label_size(LabelSize::Small)
597 .color(Color::Warning)
598 .start_icon(
599 Icon::new(IconName::Warning)
600 .size(IconSize::Small)
601 .color(Color::Warning),
602 )
603 .tooltip(|_, cx| {
604 Tooltip::with_meta(
605 "You're in Restricted Mode",
606 Some(&ToggleWorktreeSecurity),
607 "Mark this project as trusted and unlock all features",
608 cx,
609 )
610 })
611 .on_click({
612 cx.listener(move |this, _, window, cx| {
613 this.workspace
614 .update(cx, |workspace, cx| {
615 workspace.show_worktree_trust_security_modal(true, window, cx)
616 })
617 .log_err();
618 })
619 });
620
621 if cfg!(macos_sdk_26) {
622 // Make up for Tahoe's traffic light buttons having less spacing around them
623 Some(div().child(button).ml_0p5().into_any_element())
624 } else {
625 Some(button.into_any_element())
626 }
627 }
628
629 pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
630 if self.project.read(cx).is_via_remote_server() {
631 return self.render_remote_project_connection(cx);
632 }
633
634 if self.project.read(cx).is_disconnected(cx) {
635 return Some(
636 Button::new("disconnected", "Disconnected")
637 .disabled(true)
638 .color(Color::Disabled)
639 .label_size(LabelSize::Small)
640 .into_any_element(),
641 );
642 }
643
644 let host = self.project.read(cx).host()?;
645 let host_user = self.user_store.read(cx).get_cached_user(host.user_id)?;
646 let participant_index = self
647 .user_store
648 .read(cx)
649 .participant_indices()
650 .get(&host_user.id)?;
651
652 Some(
653 Button::new("project_owner_trigger", host_user.github_login.clone())
654 .color(Color::Player(participant_index.0))
655 .label_size(LabelSize::Small)
656 .tooltip(move |_, cx| {
657 let tooltip_title = format!(
658 "{} is sharing this project. Click to follow.",
659 host_user.github_login
660 );
661
662 Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx)
663 })
664 .on_click({
665 let host_peer_id = host.peer_id;
666 cx.listener(move |this, _, window, cx| {
667 this.workspace
668 .update(cx, |workspace, cx| {
669 workspace.follow(host_peer_id, window, cx);
670 })
671 .log_err();
672 })
673 })
674 .into_any_element(),
675 )
676 }
677
678 fn render_project_name(
679 &self,
680 name: Option<SharedString>,
681 _: &mut Window,
682 cx: &mut Context<Self>,
683 ) -> impl IntoElement {
684 let workspace = self.workspace.clone();
685
686 let is_project_selected = name.is_some();
687
688 let display_name = if let Some(ref name) = name {
689 util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH)
690 } else {
691 "Open Recent Project".to_string()
692 };
693
694 let is_sidebar_open = self
695 .multi_workspace
696 .as_ref()
697 .and_then(|mw| mw.upgrade())
698 .map(|mw| mw.read(cx).sidebar_open())
699 .unwrap_or(false)
700 && PlatformTitleBar::is_multi_workspace_enabled(cx);
701
702 let is_threads_list_view_active = self
703 .multi_workspace
704 .as_ref()
705 .and_then(|mw| mw.upgrade())
706 .map(|mw| mw.read(cx).is_threads_list_view_active(cx))
707 .unwrap_or(false);
708
709 if is_sidebar_open && is_threads_list_view_active {
710 return self
711 .render_recent_projects_popover(display_name, is_project_selected, cx)
712 .into_any_element();
713 }
714
715 let focus_handle = workspace
716 .upgrade()
717 .map(|w| w.read(cx).focus_handle(cx))
718 .unwrap_or_else(|| cx.focus_handle());
719
720 let window_project_groups: Vec<_> = self
721 .multi_workspace
722 .as_ref()
723 .and_then(|mw| mw.upgrade())
724 .map(|mw| mw.read(cx).project_group_keys().cloned().collect())
725 .unwrap_or_default();
726
727 PopoverMenu::new("recent-projects-menu")
728 .menu(move |window, cx| {
729 Some(recent_projects::RecentProjects::popover(
730 workspace.clone(),
731 window_project_groups.clone(),
732 false,
733 focus_handle.clone(),
734 window,
735 cx,
736 ))
737 })
738 .trigger_with_tooltip(
739 Button::new("project_name_trigger", display_name)
740 .label_size(LabelSize::Small)
741 .when(self.worktree_count(cx) > 1, |this| {
742 this.end_icon(
743 Icon::new(IconName::ChevronDown)
744 .size(IconSize::XSmall)
745 .color(Color::Muted),
746 )
747 })
748 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
749 .when(!is_project_selected, |s| s.color(Color::Muted)),
750 move |_window, cx| {
751 Tooltip::for_action(
752 "Recent Projects",
753 &zed_actions::OpenRecent {
754 create_new_window: false,
755 },
756 cx,
757 )
758 },
759 )
760 .anchor(gpui::Corner::TopLeft)
761 .into_any_element()
762 }
763
764 fn render_recent_projects_popover(
765 &self,
766 display_name: String,
767 is_project_selected: bool,
768 cx: &mut Context<Self>,
769 ) -> impl IntoElement {
770 let workspace = self.workspace.clone();
771
772 let focus_handle = workspace
773 .upgrade()
774 .map(|w| w.read(cx).focus_handle(cx))
775 .unwrap_or_else(|| cx.focus_handle());
776
777 let window_project_groups: Vec<_> = self
778 .multi_workspace
779 .as_ref()
780 .and_then(|mw| mw.upgrade())
781 .map(|mw| mw.read(cx).project_group_keys().cloned().collect())
782 .unwrap_or_default();
783
784 PopoverMenu::new("sidebar-title-recent-projects-menu")
785 .menu(move |window, cx| {
786 Some(recent_projects::RecentProjects::popover(
787 workspace.clone(),
788 window_project_groups.clone(),
789 false,
790 focus_handle.clone(),
791 window,
792 cx,
793 ))
794 })
795 .trigger_with_tooltip(
796 Button::new("project_name_trigger", display_name)
797 .label_size(LabelSize::Small)
798 .when(self.worktree_count(cx) > 1, |this| {
799 this.end_icon(
800 Icon::new(IconName::ChevronDown)
801 .size(IconSize::XSmall)
802 .color(Color::Muted),
803 )
804 })
805 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
806 .when(!is_project_selected, |s| s.color(Color::Muted)),
807 move |_window, cx| {
808 Tooltip::for_action(
809 "Recent Projects",
810 &zed_actions::OpenRecent {
811 create_new_window: false,
812 },
813 cx,
814 )
815 },
816 )
817 .anchor(gpui::Corner::TopLeft)
818 }
819
820 fn render_project_branch(
821 &self,
822 repository: Entity<project::git_store::Repository>,
823 linked_worktree_name: Option<SharedString>,
824 cx: &mut Context<Self>,
825 ) -> Option<impl IntoElement> {
826 let workspace = self.workspace.upgrade()?;
827
828 let (branch_name, icon_info) = {
829 let repo = repository.read(cx);
830
831 let branch_name = repo
832 .branch
833 .as_ref()
834 .map(|branch| branch.name())
835 .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
836 .or_else(|| {
837 repo.head_commit.as_ref().map(|commit| {
838 commit
839 .sha
840 .chars()
841 .take(MAX_SHORT_SHA_LENGTH)
842 .collect::<String>()
843 })
844 });
845
846 let status = repo.status_summary();
847 let tracked = status.index + status.worktree;
848 let icon_info = if status.conflict > 0 {
849 (IconName::Warning, Color::VersionControlConflict)
850 } else if tracked.modified > 0 {
851 (IconName::SquareDot, Color::VersionControlModified)
852 } else if tracked.added > 0 || status.untracked > 0 {
853 (IconName::SquarePlus, Color::VersionControlAdded)
854 } else if tracked.deleted > 0 {
855 (IconName::SquareMinus, Color::VersionControlDeleted)
856 } else {
857 (IconName::GitBranch, Color::Muted)
858 };
859
860 (branch_name, icon_info)
861 };
862
863 let branch_name = branch_name?;
864 let settings = TitleBarSettings::get_global(cx);
865 let effective_repository = Some(repository);
866
867 Some(
868 PopoverMenu::new("branch-menu")
869 .menu(move |window, cx| {
870 Some(git_ui::git_picker::popover(
871 workspace.downgrade(),
872 effective_repository.clone(),
873 git_ui::git_picker::GitPickerTab::Branches,
874 gpui::rems(34.),
875 window,
876 cx,
877 ))
878 })
879 .trigger_with_tooltip(
880 ButtonLike::new("project_branch_trigger")
881 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
882 .child(
883 h_flex()
884 .gap_0p5()
885 .when(settings.show_branch_icon, |this| {
886 let (icon, icon_color) = icon_info;
887 this.child(
888 Icon::new(icon).size(IconSize::XSmall).color(icon_color),
889 )
890 })
891 .when_some(linked_worktree_name.as_ref(), |this, worktree_name| {
892 this.child(
893 Label::new(worktree_name)
894 .size(LabelSize::Small)
895 .color(Color::Muted),
896 )
897 .child(
898 Label::new("/").size(LabelSize::Small).color(
899 Color::Custom(
900 cx.theme().colors().text_muted.opacity(0.4),
901 ),
902 ),
903 )
904 })
905 .child(
906 Label::new(branch_name)
907 .size(LabelSize::Small)
908 .color(Color::Muted),
909 ),
910 ),
911 move |_window, cx| {
912 Tooltip::with_meta(
913 "Git Switcher",
914 Some(&zed_actions::git::Branch),
915 "Worktrees, Branches, and Stashes",
916 cx,
917 )
918 },
919 )
920 .anchor(gpui::Corner::TopLeft),
921 )
922 }
923
924 fn window_activation_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
925 if window.is_window_active() {
926 ActiveCall::global(cx)
927 .update(cx, |call, cx| call.set_location(Some(&self.project), cx))
928 .detach_and_log_err(cx);
929 } else if cx.active_window().is_none() {
930 ActiveCall::global(cx)
931 .update(cx, |call, cx| call.set_location(None, cx))
932 .detach_and_log_err(cx);
933 }
934 self.workspace
935 .update(cx, |workspace, cx| {
936 workspace.update_active_view_for_followers(window, cx);
937 })
938 .ok();
939 }
940
941 fn active_call_changed(&mut self, cx: &mut Context<Self>) {
942 self.observe_diagnostics(cx);
943 cx.notify();
944 }
945
946 fn observe_diagnostics(&mut self, cx: &mut Context<Self>) {
947 let diagnostics = ActiveCall::global(cx)
948 .read(cx)
949 .room()
950 .and_then(|room| room.read(cx).diagnostics().cloned());
951
952 if let Some(diagnostics) = diagnostics {
953 self._diagnostics_subscription = Some(cx.observe(&diagnostics, |_, _, cx| cx.notify()));
954 } else {
955 self._diagnostics_subscription = None;
956 }
957 }
958
959 fn share_project(&mut self, cx: &mut Context<Self>) {
960 let active_call = ActiveCall::global(cx);
961 let project = self.project.clone();
962 active_call
963 .update(cx, |call, cx| call.share_project(project, cx))
964 .detach_and_log_err(cx);
965 }
966
967 fn unshare_project(&mut self, _: &mut Window, cx: &mut Context<Self>) {
968 let active_call = ActiveCall::global(cx);
969 let project = self.project.clone();
970 active_call
971 .update(cx, |call, cx| call.unshare_project(project, cx))
972 .log_err();
973 }
974
975 fn render_connection_status(
976 &self,
977 status: &client::Status,
978 cx: &mut Context<Self>,
979 ) -> Option<AnyElement> {
980 match status {
981 client::Status::ConnectionError
982 | client::Status::ConnectionLost
983 | client::Status::Reauthenticating
984 | client::Status::Reconnecting
985 | client::Status::ReconnectionError { .. } => Some(
986 div()
987 .id("disconnected")
988 .child(Icon::new(IconName::Disconnected).size(IconSize::Small))
989 .tooltip(Tooltip::text("Disconnected"))
990 .into_any_element(),
991 ),
992 client::Status::UpgradeRequired => {
993 let auto_updater = auto_update::AutoUpdater::get(cx);
994 let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
995 Some(AutoUpdateStatus::Updated { .. }) => "Please restart Zed to Collaborate",
996 Some(AutoUpdateStatus::Installing { .. })
997 | Some(AutoUpdateStatus::Downloading { .. })
998 | Some(AutoUpdateStatus::Checking) => "Updating...",
999 Some(AutoUpdateStatus::Idle)
1000 | Some(AutoUpdateStatus::Errored { .. })
1001 | None => "Please update Zed to Collaborate",
1002 };
1003
1004 Some(
1005 Button::new("connection-status", label)
1006 .label_size(LabelSize::Small)
1007 .on_click(|_, window, cx| {
1008 if let Some(auto_updater) = auto_update::AutoUpdater::get(cx)
1009 && auto_updater.read(cx).status().is_updated()
1010 {
1011 workspace::reload(cx);
1012 return;
1013 }
1014 auto_update::check(&Default::default(), window, cx);
1015 })
1016 .into_any_element(),
1017 )
1018 }
1019 _ => None,
1020 }
1021 }
1022
1023 pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
1024 let client = self.client.clone();
1025 let workspace = self.workspace.clone();
1026 Button::new("sign_in", "Sign In")
1027 .label_size(LabelSize::Small)
1028 .on_click(move |_, window, cx| {
1029 let client = client.clone();
1030 let workspace = workspace.clone();
1031 window
1032 .spawn(cx, async move |mut cx| {
1033 client
1034 .sign_in_with_optional_connect(true, cx)
1035 .await
1036 .notify_workspace_async_err(workspace, &mut cx);
1037 })
1038 .detach();
1039 })
1040 }
1041
1042 pub fn render_user_menu_button(&mut self, cx: &mut Context<Self>) -> impl Element {
1043 let show_update_button = self.update_version.read(cx).show_update_in_menu_bar();
1044
1045 let user_store = self.user_store.clone();
1046 let user_store_read = user_store.read(cx);
1047 let user = user_store_read.current_user();
1048
1049 let user_avatar = user.as_ref().map(|u| u.avatar_uri.clone());
1050 let user_login = user.as_ref().map(|u| u.github_login.clone());
1051
1052 let is_signed_in = user.is_some();
1053
1054 let has_subscription_period = user_store_read.subscription_period().is_some();
1055 let plan = user_store_read.plan().filter(|_| {
1056 // Since the user might be on the legacy free plan we filter based on whether we have a subscription period.
1057 has_subscription_period
1058 });
1059
1060 let has_organization = user_store_read.current_organization().is_some();
1061
1062 let current_organization = user_store_read.current_organization();
1063 let business_organization = current_organization
1064 .as_ref()
1065 .filter(|organization| !organization.is_personal);
1066 let organizations: Vec<_> = user_store_read
1067 .organizations()
1068 .iter()
1069 .map(|org| {
1070 let plan = user_store_read.plan_for_organization(&org.id);
1071 (org.clone(), plan)
1072 })
1073 .collect();
1074
1075 let show_user_picture = TitleBarSettings::get_global(cx).show_user_picture;
1076
1077 let trigger = if is_signed_in && show_user_picture {
1078 let avatar = user_avatar.map(|avatar| Avatar::new(avatar)).map(|avatar| {
1079 if show_update_button {
1080 avatar.indicator(
1081 div()
1082 .absolute()
1083 .bottom_0()
1084 .right_0()
1085 .child(Indicator::dot().color(Color::Accent)),
1086 )
1087 } else {
1088 avatar
1089 }
1090 });
1091
1092 ButtonLike::new("user-menu").child(
1093 h_flex()
1094 .when_some(business_organization, |this, organization| {
1095 this.gap_2()
1096 .child(Label::new(&organization.name).size(LabelSize::Small))
1097 })
1098 .children(avatar),
1099 )
1100 } else {
1101 ButtonLike::new("user-menu")
1102 .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
1103 };
1104
1105 PopoverMenu::new("user-menu")
1106 .trigger(trigger)
1107 .menu(move |window, cx| {
1108 let user_login = user_login.clone();
1109 let current_organization = current_organization.clone();
1110 let organizations = organizations.clone();
1111 let user_store = user_store.clone();
1112
1113 ContextMenu::build(window, cx, |menu, _, _cx| {
1114 menu.when(is_signed_in, |this| {
1115 let user_login = user_login.clone();
1116 this.custom_entry(
1117 move |_window, _cx| {
1118 let user_login = user_login.clone().unwrap_or_default();
1119
1120 h_flex()
1121 .w_full()
1122 .justify_between()
1123 .child(Label::new(user_login))
1124 .child(PlanChip::new(plan.unwrap_or(Plan::ZedFree)))
1125 .into_any_element()
1126 },
1127 move |_, cx| {
1128 cx.open_url(&zed_urls::account_url(cx));
1129 },
1130 )
1131 .separator()
1132 })
1133 .when(show_update_button, |this| {
1134 this.custom_entry(
1135 move |_window, _cx| {
1136 h_flex()
1137 .w_full()
1138 .gap_1()
1139 .justify_between()
1140 .child(Label::new("Restart to update Zed").color(Color::Accent))
1141 .child(
1142 Icon::new(IconName::Download)
1143 .size(IconSize::Small)
1144 .color(Color::Accent),
1145 )
1146 .into_any_element()
1147 },
1148 move |_, cx| {
1149 workspace::reload(cx);
1150 },
1151 )
1152 .separator()
1153 })
1154 .when(has_organization, |this| {
1155 let mut this = this.header("Organization");
1156
1157 for (organization, plan) in &organizations {
1158 let organization = organization.clone();
1159 let plan = *plan;
1160
1161 let is_current =
1162 current_organization
1163 .as_ref()
1164 .is_some_and(|current_organization| {
1165 current_organization.id == organization.id
1166 });
1167
1168 this = this.custom_entry(
1169 {
1170 let organization = organization.clone();
1171 move |_window, _cx| {
1172 h_flex()
1173 .w_full()
1174 .gap_4()
1175 .justify_between()
1176 .child(
1177 h_flex()
1178 .gap_1()
1179 .child(Label::new(&organization.name))
1180 .when(is_current, |this| {
1181 this.child(
1182 Icon::new(IconName::Check)
1183 .color(Color::Accent),
1184 )
1185 }),
1186 )
1187 .child(PlanChip::new(plan.unwrap_or(Plan::ZedFree)))
1188 .into_any_element()
1189 }
1190 },
1191 {
1192 let user_store = user_store.clone();
1193 let organization = organization.clone();
1194 move |_window, cx| {
1195 user_store.update(cx, |user_store, cx| {
1196 user_store
1197 .set_current_organization(organization.clone(), cx);
1198 });
1199 }
1200 },
1201 );
1202 }
1203
1204 this.separator()
1205 })
1206 .action("Settings", zed_actions::OpenSettings.boxed_clone())
1207 .action("Keymap", Box::new(zed_actions::OpenKeymap))
1208 .action(
1209 "Themes…",
1210 zed_actions::theme_selector::Toggle::default().boxed_clone(),
1211 )
1212 .action(
1213 "Icon Themes…",
1214 zed_actions::icon_theme_selector::Toggle::default().boxed_clone(),
1215 )
1216 .action(
1217 "Extensions",
1218 zed_actions::Extensions::default().boxed_clone(),
1219 )
1220 .when(is_signed_in, |this| {
1221 this.separator()
1222 .action("Sign Out", client::SignOut.boxed_clone())
1223 })
1224 })
1225 .into()
1226 })
1227 .anchor(Corner::TopRight)
1228 }
1229}