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