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