zeta: Onboarding and title bar banner (#23797)
Release Notes: - N/A --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: Danilo <danilo@zed.dev> Co-authored-by: João Marcos <joao@zed.dev>
This commit is contained in:
parent
4ab372d6b5
commit
e23e03592b
35 changed files with 1207 additions and 249 deletions
31
crates/zed_predict_onboarding/Cargo.toml
Normal file
31
crates/zed_predict_onboarding/Cargo.toml
Normal file
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "zed_predict_onboarding"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
client.workspace = true
|
||||
db.workspace = true
|
||||
feature_flags.workspace = true
|
||||
fs.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
menu.workspace = true
|
||||
settings.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
1
crates/zed_predict_onboarding/LICENSE-GPL
Symbolic link
1
crates/zed_predict_onboarding/LICENSE-GPL
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE-GPL
|
168
crates/zed_predict_onboarding/src/banner.rs
Normal file
168
crates/zed_predict_onboarding/src/banner.rs
Normal file
|
@ -0,0 +1,168 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::ZedPredictModal;
|
||||
use chrono::Utc;
|
||||
use client::{Client, UserStore};
|
||||
use feature_flags::{FeatureFlagAppExt as _, PredictEditsFeatureFlag};
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, Subscription, WeakEntity};
|
||||
use language::language_settings::{all_language_settings, InlineCompletionProvider};
|
||||
use settings::SettingsStore;
|
||||
use ui::{prelude::*, ButtonLike, Tooltip};
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
/// Prompts user to try AI inline prediction feature
|
||||
pub struct ZedPredictBanner {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
fs: Arc<dyn Fs>,
|
||||
dismissed: bool,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ZedPredictBanner {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
user_store,
|
||||
client,
|
||||
fs,
|
||||
dismissed: get_dismissed(),
|
||||
_subscription: cx.observe_global::<SettingsStore>(Self::handle_settings_changed),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_show(&self, cx: &mut App) -> bool {
|
||||
if !cx.has_flag::<PredictEditsFeatureFlag>() || self.dismissed {
|
||||
return false;
|
||||
}
|
||||
|
||||
let provider = all_language_settings(None, cx).inline_completions.provider;
|
||||
|
||||
match provider {
|
||||
InlineCompletionProvider::None
|
||||
| InlineCompletionProvider::Copilot
|
||||
| InlineCompletionProvider::Supermaven => true,
|
||||
InlineCompletionProvider::Zed => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_settings_changed(&mut self, cx: &mut Context<Self>) {
|
||||
if self.dismissed {
|
||||
return;
|
||||
}
|
||||
|
||||
let provider = all_language_settings(None, cx).inline_completions.provider;
|
||||
|
||||
match provider {
|
||||
InlineCompletionProvider::None
|
||||
| InlineCompletionProvider::Copilot
|
||||
| InlineCompletionProvider::Supermaven => {}
|
||||
InlineCompletionProvider::Zed => {
|
||||
self.dismiss(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, cx: &mut Context<Self>) {
|
||||
persist_dismissed(cx);
|
||||
self.dismissed = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
const DISMISSED_AT_KEY: &str = "zed_predict_banner_dismissed_at";
|
||||
|
||||
pub(crate) fn get_dismissed() -> bool {
|
||||
db::kvp::KEY_VALUE_STORE
|
||||
.read_kvp(DISMISSED_AT_KEY)
|
||||
.log_err()
|
||||
.map_or(false, |dismissed| dismissed.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn persist_dismissed(cx: &mut App) {
|
||||
cx.spawn(|_| {
|
||||
let time = Utc::now().to_rfc3339();
|
||||
db::kvp::KEY_VALUE_STORE.write_kvp(DISMISSED_AT_KEY.into(), time)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
impl Render for ZedPredictBanner {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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_md()
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.child(
|
||||
ButtonLike::new("try-zed-predict")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(IconName::ZedPredict).size(IconSize::Small))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new("Introducing:")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Edit Prediction").size(LabelSize::Small)),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let workspace = self.workspace.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let client = self.client.clone();
|
||||
let fs = self.fs.clone();
|
||||
move |_, window, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
ZedPredictModal::toggle(
|
||||
workspace,
|
||||
user_store.clone(),
|
||||
client.clone(),
|
||||
fs.clone(),
|
||||
window,
|
||||
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_1().child(banner)
|
||||
}
|
||||
}
|
5
crates/zed_predict_onboarding/src/lib.rs
Normal file
5
crates/zed_predict_onboarding/src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod banner;
|
||||
mod modal;
|
||||
|
||||
pub use banner::ZedPredictBanner;
|
||||
pub use modal::ZedPredictModal;
|
313
crates/zed_predict_onboarding/src/modal.rs
Normal file
313
crates/zed_predict_onboarding/src/modal.rs
Normal file
|
@ -0,0 +1,313 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use client::{Client, UserStore};
|
||||
use feature_flags::FeatureFlagAppExt as _;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
ease_in_out, svg, Animation, AnimationExt as _, ClickEvent, DismissEvent, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, MouseDownEvent, Render,
|
||||
};
|
||||
use language::language_settings::{AllLanguageSettings, InlineCompletionProvider};
|
||||
use settings::{update_settings_file, Settings};
|
||||
use ui::{prelude::*, CheckboxWithLabel, TintColor};
|
||||
use workspace::{notifications::NotifyTaskExt, ModalView, Workspace};
|
||||
|
||||
/// Introduces user to AI inline prediction feature and terms of service
|
||||
pub struct ZedPredictModal {
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
fs: Arc<dyn Fs>,
|
||||
focus_handle: FocusHandle,
|
||||
sign_in_status: SignInStatus,
|
||||
terms_of_service: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum SignInStatus {
|
||||
/// Signed out or signed in but not from this modal
|
||||
Idle,
|
||||
/// Authentication triggered from this modal
|
||||
Waiting,
|
||||
/// Signed in after authentication from this modal
|
||||
SignedIn,
|
||||
}
|
||||
|
||||
impl ZedPredictModal {
|
||||
fn new(
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
ZedPredictModal {
|
||||
user_store,
|
||||
client,
|
||||
fs,
|
||||
focus_handle: cx.focus_handle(),
|
||||
sign_in_status: SignInStatus::Idle,
|
||||
terms_of_service: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(
|
||||
workspace: Entity<Workspace>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
workspace.update(cx, |this, cx| {
|
||||
this.toggle_modal(window, cx, |_window, cx| {
|
||||
ZedPredictModal::new(user_store, client, fs, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn view_terms(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.open_url("https://zed.dev/terms-of-service");
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.open_url("https://zed.dev/blog/"); // TODO Add the link when live
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn accept_and_enable(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task = self
|
||||
.user_store
|
||||
.update(cx, |this, cx| this.accept_terms_of_service(cx));
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
task.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
update_settings_file::<AllLanguageSettings>(this.fs.clone(), cx, move |file, _| {
|
||||
file.features
|
||||
.get_or_insert(Default::default())
|
||||
.inline_completion_provider = Some(InlineCompletionProvider::Zed);
|
||||
});
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
})
|
||||
})
|
||||
.detach_and_notify_err(window, cx);
|
||||
}
|
||||
|
||||
fn sign_in(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
self.sign_in_status = SignInStatus::Waiting;
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let result = client.authenticate_and_connect(true, &cx).await;
|
||||
|
||||
let status = match result {
|
||||
Ok(_) => SignInStatus::SignedIn,
|
||||
Err(_) => SignInStatus::Idle,
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.sign_in_status = status;
|
||||
cx.notify()
|
||||
})?;
|
||||
|
||||
result
|
||||
})
|
||||
.detach_and_notify_err(window, cx);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ZedPredictModal {}
|
||||
|
||||
impl Focusable for ZedPredictModal {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ZedPredictModal {}
|
||||
|
||||
impl Render for ZedPredictModal {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let base = v_flex()
|
||||
.w(px(420.))
|
||||
.p_4()
|
||||
.relative()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.elevation_3(cx)
|
||||
.id("zed predict tos")
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.key_context("ZedPredictModal")
|
||||
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
|
||||
this.focus_handle.focus(window);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.p_1p5()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.h(px(200.))
|
||||
.child(
|
||||
svg()
|
||||
.path("icons/zed_predict_bg.svg")
|
||||
.text_color(cx.theme().colors().icon_disabled)
|
||||
.w(px(416.))
|
||||
.h(px(128.))
|
||||
.overflow_hidden(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.mb_2()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new("Introducing Zed AI's")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Headline::new("Edit Prediction").size(HeadlineSize::Large)),
|
||||
)
|
||||
.child({
|
||||
let tab = |n: usize| {
|
||||
let text_color = cx.theme().colors().text;
|
||||
let border_color = cx.theme().colors().text_accent.opacity(0.4);
|
||||
|
||||
h_flex().child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_0p5()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.rounded_md()
|
||||
.font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
|
||||
.text_size(TextSize::XSmall.rems(cx))
|
||||
.text_color(text_color)
|
||||
.child("tab")
|
||||
.with_animation(
|
||||
ElementId::Integer(n),
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
move |tab, delta| {
|
||||
let delta = (delta - 0.15 * n as f32) / 0.7;
|
||||
let delta = 1.0 - (0.5 - delta).abs() * 2.;
|
||||
let delta = ease_in_out(delta.clamp(0., 1.));
|
||||
let delta = 0.1 + 0.9 * delta;
|
||||
|
||||
tab.border_color(border_color.opacity(delta))
|
||||
.text_color(text_color.opacity(delta))
|
||||
},
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.pr_4()
|
||||
.child(tab(0).ml_neg_20())
|
||||
.child(tab(1))
|
||||
.child(tab(2).ml_20())
|
||||
}),
|
||||
)
|
||||
.child(h_flex().absolute().top_2().right_2().child(
|
||||
IconButton::new("cancel", IconName::X).on_click(cx.listener(
|
||||
|_, _: &ClickEvent, _window, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
)),
|
||||
));
|
||||
|
||||
let blog_post_button = if cx.is_staff() {
|
||||
Some(
|
||||
Button::new("view-blog", "Read the Blog Post")
|
||||
.full_width()
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_size(IconSize::Indicator)
|
||||
.icon_color(Color::Muted)
|
||||
.on_click(cx.listener(Self::view_blog)),
|
||||
)
|
||||
} else {
|
||||
// TODO: put back when blog post is published
|
||||
None
|
||||
};
|
||||
|
||||
if self.user_store.read(cx).current_user().is_some() {
|
||||
let copy = match self.sign_in_status {
|
||||
SignInStatus::Idle => "Get accurate and helpful edit predictions at every keystroke. To set Zed as your inline completions provider, ensure you:",
|
||||
SignInStatus::SignedIn => "Almost there! Ensure you:",
|
||||
SignInStatus::Waiting => unreachable!(),
|
||||
};
|
||||
|
||||
base.child(Label::new(copy).color(Color::Muted))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(CheckboxWithLabel::new(
|
||||
"tos-checkbox",
|
||||
Label::new("Have read and accepted the").color(Color::Muted),
|
||||
self.terms_of_service.into(),
|
||||
cx.listener(move |this, state, _window, cx| {
|
||||
this.terms_of_service = *state == ToggleState::Selected;
|
||||
cx.notify()
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
Button::new("view-tos", "Terms of Service")
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_size(IconSize::Indicator)
|
||||
.icon_color(Color::Muted)
|
||||
.on_click(cx.listener(Self::view_terms)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("accept-tos", "Enable Edit Predictions")
|
||||
.disabled(!self.terms_of_service)
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.full_width()
|
||||
.on_click(cx.listener(Self::accept_and_enable)),
|
||||
)
|
||||
.children(blog_post_button),
|
||||
)
|
||||
} else {
|
||||
base.child(
|
||||
Label::new("To set Zed as your inline completions provider, please sign in.")
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("accept-tos", "Sign in with GitHub")
|
||||
.disabled(self.sign_in_status == SignInStatus::Waiting)
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.full_width()
|
||||
.on_click(cx.listener(Self::sign_in)),
|
||||
)
|
||||
.children(blog_post_button),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue