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