diff --git a/Cargo.lock b/Cargo.lock
index e90baba51731723782f5f9cba7c550dafc5eab7a..ed04063d10576fb8a0864b1ac0964e54c36abd64 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5450,7 +5450,9 @@ dependencies = [
"askpass",
"assistant_settings",
"buffer_diff",
+ "chrono",
"collections",
+ "command_palette_hooks",
"component",
"ctor",
"db",
@@ -13984,6 +13986,7 @@ dependencies = [
"client",
"collections",
"feature_flags",
+ "git_ui",
"gpui",
"http_client",
"notifications",
diff --git a/assets/icons/git_onboarding_bg.svg b/assets/icons/git_onboarding_bg.svg
new file mode 100644
index 0000000000000000000000000000000000000000..18da0230a26c4e67b7e3ac2e64894132102f93c6
--- /dev/null
+++ b/assets/icons/git_onboarding_bg.svg
@@ -0,0 +1,40 @@
+
diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml
index 4d7d300d19ef7d65d531abd7fcf44f4628726f68..e0be28e20b1a4385b802648be781a2fab10ad9ea 100644
--- a/crates/git_ui/Cargo.toml
+++ b/crates/git_ui/Cargo.toml
@@ -21,7 +21,9 @@ anyhow.workspace = true
askpass.workspace = true
assistant_settings.workspace = true
buffer_diff.workspace = true
+chrono.workspace = true
collections.workspace = true
+command_palette_hooks.workspace = true
component.workspace = true
db.workspace = true
editor.workspace = true
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 48c1eb19e7c6a0b1e2f8e13e9088d031e5ad92a3..28ce505ab3bac8e15b95b5126f43a89f1fd2be81 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -702,7 +702,10 @@ impl GitPanel {
let mut dispatch_context = KeyContext::new_with_defaults();
dispatch_context.add("GitPanel");
- if self.is_focused(window, cx) {
+ if window
+ .focused(cx)
+ .map_or(false, |focused| self.focus_handle == focused)
+ {
dispatch_context.add("menu");
dispatch_context.add("ChangesList");
}
@@ -714,12 +717,6 @@ impl GitPanel {
dispatch_context
}
- fn is_focused(&self, window: &Window, cx: &Context) -> bool {
- window
- .focused(cx)
- .map_or(false, |focused| self.focus_handle == focused)
- }
-
fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context) {
cx.emit(PanelEvent::Close);
}
@@ -3811,8 +3808,12 @@ impl Render for GitPanel {
}
impl Focusable for GitPanel {
- fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
- self.focus_handle.clone()
+ fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+ if self.entries.is_empty() {
+ self.commit_editor.focus_handle(cx)
+ } else {
+ self.focus_handle.clone()
+ }
}
}
diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs
index 5e10289f1c1e8cdd2cefcb01d1d9437881d95891..27499edeea7fb789774cef14d239353b0c301ce1 100644
--- a/crates/git_ui/src/git_ui.rs
+++ b/crates/git_ui/src/git_ui.rs
@@ -1,10 +1,14 @@
+use std::any::Any;
+
use ::settings::Settings;
+use command_palette_hooks::CommandPaletteFilter;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
};
use git_panel_settings::GitPanelSettings;
-use gpui::{App, Entity, FocusHandle};
+use gpui::{actions, App, Entity, FocusHandle};
+use onboarding::{clear_dismissed, GitOnboardingModal};
use project::Project;
use project_diff::ProjectDiff;
use ui::prelude::*;
@@ -15,11 +19,14 @@ pub mod branch_picker;
mod commit_modal;
pub mod git_panel;
mod git_panel_settings;
+pub mod onboarding;
pub mod picker_prompt;
pub mod project_diff;
pub(crate) mod remote_output;
pub mod repository_selector;
+actions!(git, [ResetOnboarding]);
+
pub fn init(cx: &mut App) {
GitPanelSettings::register(cx);
branch_picker::init(cx);
@@ -82,6 +89,21 @@ pub fn init(cx: &mut App) {
panel.unstage_all(action, window, cx);
});
});
+ CommandPaletteFilter::update_global(cx, |filter, _cx| {
+ filter.hide_action_types(&[
+ zed_actions::OpenGitIntegrationOnboarding.type_id(),
+ // ResetOnboarding.type_id(),
+ ]);
+ });
+ workspace.register_action(
+ move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
+ GitOnboardingModal::toggle(workspace, window, cx)
+ },
+ );
+ workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
+ clear_dismissed(cx);
+ window.refresh();
+ });
workspace.register_action(|workspace, _action: &git::Init, window, cx| {
let Some(panel) = workspace.panel::(cx) else {
return;
diff --git a/crates/git_ui/src/onboarding.rs b/crates/git_ui/src/onboarding.rs
new file mode 100644
index 0000000000000000000000000000000000000000..68a4a3f42070033f8539f66e9ddf634a0c7a3b8f
--- /dev/null
+++ b/crates/git_ui/src/onboarding.rs
@@ -0,0 +1,267 @@
+use gpui::{
+ svg, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
+ MouseDownEvent, Render,
+};
+use ui::{prelude::*, ButtonLike, TintColor, Tooltip};
+use util::ResultExt;
+use workspace::{ModalView, Workspace};
+
+use crate::git_panel::GitPanel;
+
+macro_rules! git_onboarding_event {
+ ($name:expr) => {
+ telemetry::event!($name, source = "Git Onboarding");
+ };
+ ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
+ telemetry::event!($name, source = "Git Onboarding", $($key $(= $value)?),+);
+ };
+}
+
+/// Introduces user to the Git Panel and overall improved Git support
+pub struct GitOnboardingModal {
+ focus_handle: FocusHandle,
+ workspace: Entity,
+}
+
+impl GitOnboardingModal {
+ pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) {
+ let workspace_entity = cx.entity();
+ workspace.toggle_modal(window, cx, |_window, cx| Self {
+ workspace: workspace_entity,
+ focus_handle: cx.focus_handle(),
+ });
+ }
+
+ fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) {
+ self.workspace.update(cx, |workspace, cx| {
+ workspace.focus_panel::(window, cx);
+ });
+
+ cx.emit(DismissEvent);
+
+ git_onboarding_event!("Open Panel Clicked");
+ }
+
+ fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) {
+ cx.open_url("https://zed.dev/blog/git");
+ cx.notify();
+
+ git_onboarding_event!("Blog Link Clicked");
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) {
+ cx.emit(DismissEvent);
+ }
+}
+
+impl EventEmitter for GitOnboardingModal {}
+
+impl Focusable for GitOnboardingModal {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl ModalView for GitOnboardingModal {}
+
+impl Render for GitOnboardingModal {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let window_height = window.viewport_size().height;
+ let max_height = window_height - px(200.);
+
+ let base = v_flex()
+ .id("git-onboarding")
+ .key_context("GitOnboardingModal")
+ .relative()
+ .w(px(450.))
+ .h_full()
+ .max_h(max_height)
+ .p_4()
+ .gap_2()
+ .elevation_3(cx)
+ .track_focus(&self.focus_handle(cx))
+ .overflow_hidden()
+ .on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
+ git_onboarding_event!("Cancelled", trigger = "Action");
+ cx.emit(DismissEvent);
+ }))
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
+ this.focus_handle.focus(window);
+ }))
+ .child(
+ div().p_1p5().absolute().inset_0().h(px(160.)).child(
+ svg()
+ .path("icons/git_onboarding_bg.svg")
+ .text_color(cx.theme().colors().icon_disabled)
+ .w(px(420.))
+ .h(px(128.))
+ .overflow_hidden(),
+ ),
+ )
+ .child(
+ v_flex()
+ .w_full()
+ .gap_1()
+ .child(
+ Label::new("Introducing")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Headline::new("Native Git Support").size(HeadlineSize::Large)),
+ )
+ .child(h_flex().absolute().top_2().right_2().child(
+ IconButton::new("cancel", IconName::X).on_click(cx.listener(
+ |_, _: &ClickEvent, _window, cx| {
+ git_onboarding_event!("Cancelled", trigger = "X click");
+ cx.emit(DismissEvent);
+ },
+ )),
+ ));
+
+ let open_panel_button = Button::new("open-panel", "Get Started with the Git Panel")
+ .icon_size(IconSize::Indicator)
+ .style(ButtonStyle::Tinted(TintColor::Accent))
+ .full_width()
+ .on_click(cx.listener(Self::open_panel));
+
+ let blog_post_button = Button::new("view-blog", "Check out the Blog Post")
+ .icon(IconName::ArrowUpRight)
+ .icon_size(IconSize::Indicator)
+ .icon_color(Color::Muted)
+ .full_width()
+ .on_click(cx.listener(Self::view_blog));
+
+ let copy = "First-class support for staging, committing, pulling, pushing, viewing diffs, and more. All without leaving Zed.";
+
+ base.child(Label::new(copy).color(Color::Muted)).child(
+ v_flex()
+ .w_full()
+ .mt_2()
+ .gap_2()
+ .child(open_panel_button)
+ .child(blog_post_button),
+ )
+ }
+}
+
+/// Prompts the user to try Zed's git features
+pub struct GitBanner {
+ dismissed: bool,
+}
+
+#[derive(Clone)]
+struct GitBannerGlobal(Entity);
+impl Global for GitBannerGlobal {}
+
+impl GitBanner {
+ pub fn new(cx: &mut Context) -> Self {
+ cx.set_global(GitBannerGlobal(cx.entity()));
+ Self {
+ dismissed: get_dismissed(),
+ }
+ }
+
+ fn should_show(&self, _cx: &mut App) -> bool {
+ !self.dismissed
+ }
+
+ fn dismiss(&mut self, cx: &mut Context) {
+ git_onboarding_event!("Banner Dismissed");
+ persist_dismissed(cx);
+ self.dismissed = true;
+ cx.notify();
+ }
+}
+
+const DISMISSED_AT_KEY: &str = "zed_git_banner_dismissed_at";
+
+fn get_dismissed() -> bool {
+ db::kvp::KEY_VALUE_STORE
+ .read_kvp(DISMISSED_AT_KEY)
+ .log_err()
+ .map_or(false, |dismissed| dismissed.is_some())
+}
+
+fn persist_dismissed(cx: &mut App) {
+ cx.spawn(|_| {
+ let time = chrono::Utc::now().to_rfc3339();
+ db::kvp::KEY_VALUE_STORE.write_kvp(DISMISSED_AT_KEY.into(), time)
+ })
+ .detach_and_log_err(cx);
+}
+
+pub(crate) fn clear_dismissed(cx: &mut App) {
+ cx.defer(|cx| {
+ cx.global::()
+ .clone()
+ .0
+ .update(cx, |this, cx| {
+ this.dismissed = false;
+ cx.notify();
+ });
+ });
+
+ cx.spawn(|_| db::kvp::KEY_VALUE_STORE.delete_kvp(DISMISSED_AT_KEY.into()))
+ .detach_and_log_err(cx);
+}
+
+impl Render for GitBanner {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ if !self.should_show(cx) {
+ return div();
+ }
+
+ let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
+ let banner = h_flex()
+ .rounded_sm()
+ .border_1()
+ .border_color(border_color)
+ .child(
+ ButtonLike::new("try-git")
+ .child(
+ h_flex()
+ .h_full()
+ .items_center()
+ .gap_1()
+ .child(Icon::new(IconName::GitBranchSmall).size(IconSize::Small))
+ .child(
+ h_flex()
+ .gap_0p5()
+ .child(
+ Label::new("Introducing:")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Label::new("Git Support").size(LabelSize::Small)),
+ ),
+ )
+ .on_click(cx.listener(|this, _, window, cx| {
+ git_onboarding_event!("Banner Clicked");
+ this.dismiss(cx);
+ window.dispatch_action(
+ Box::new(zed_actions::OpenGitIntegrationOnboarding),
+ cx,
+ )
+ })),
+ )
+ .child(
+ div().border_l_1().border_color(border_color).child(
+ IconButton::new("close", IconName::Close)
+ .icon_size(IconSize::Indicator)
+ .on_click(cx.listener(|this, _, _window, cx| this.dismiss(cx)))
+ .tooltip(|window, cx| {
+ Tooltip::with_meta(
+ "Close Announcement Banner",
+ None,
+ "It won't show again for this feature",
+ window,
+ cx,
+ )
+ }),
+ ),
+ );
+
+ div().pr_2().child(banner)
+ }
+}
diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml
index 7b642aa7a79bec9fc2a9c31a6bd6e02c2e270c56..f5265bdc4f6b381604c1913236e930a439a6a0dd 100644
--- a/crates/title_bar/Cargo.toml
+++ b/crates/title_bar/Cargo.toml
@@ -48,6 +48,7 @@ util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zeta.workspace = true
+git_ui.workspace = true
[target.'cfg(windows)'.dependencies]
windows.workspace = true
diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs
index de514d1339a91e4064af725d30951d142c889354..6045c7a5b17852d597818fa65fe4ef551065c7ed 100644
--- a/crates/title_bar/src/title_bar.rs
+++ b/crates/title_bar/src/title_bar.rs
@@ -18,6 +18,7 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore};
use feature_flags::{FeatureFlagAppExt, ZedPro};
+use git_ui::onboarding::GitBanner;
use gpui::{
actions, div, px, Action, AnyElement, App, Context, Decorations, Element, Entity,
InteractiveElement, Interactivity, IntoElement, MouseButton, ParentElement, Render, Stateful,
@@ -126,6 +127,7 @@ pub struct TitleBar {
application_menu: Option>,
_subscriptions: Vec,
zed_predict_banner: Entity,
+ git_banner: Entity,
}
impl Render for TitleBar {
@@ -210,6 +212,7 @@ impl Render for TitleBar {
)
.child(self.render_collaborator_list(window, cx))
.child(self.zed_predict_banner.clone())
+ .child(self.git_banner.clone())
.child(
h_flex()
.gap_1()
@@ -313,6 +316,7 @@ impl TitleBar {
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
let zed_predict_banner = cx.new(ZedPredictBanner::new);
+ let git_banner = cx.new(GitBanner::new);
Self {
platform_style,
@@ -326,6 +330,7 @@ impl TitleBar {
client,
_subscriptions: subscriptions,
zed_predict_banner,
+ git_banner,
}
}
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
index d32582b5b561fe476c1b641a3005d908b7d533f2..3c6af8c8382fd9419f2438e81b42c3828ebb0258 100644
--- a/crates/zed_actions/src/lib.rs
+++ b/crates/zed_actions/src/lib.rs
@@ -262,3 +262,4 @@ pub mod outline {
}
actions!(zed_predict_onboarding, [OpenZedPredictOnboarding]);
+actions!(git_onboarding, [OpenGitIntegrationOnboarding]);