zeta: Revised data-collection onboarding experience (#24031)

Release Notes:

- N/A

---------

Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: João Marcos <marcospb19@hotmail.com>
This commit is contained in:
Agus Zubiaga
2025-02-04 04:06:09 -03:00
committed by GitHub
co-authored by Danilo Danilo Leal João Marcos
parent 29e559d60c
commit 93f8ccaaee
31 changed files with 760 additions and 601 deletions
+60
View File
@@ -0,0 +1,60 @@
use std::any::{Any, TypeId};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::{
FeatureFlagAppExt as _, PredictEditsFeatureFlag, PredictEditsRateCompletionsFeatureFlag,
};
use ui::App;
use workspace::Workspace;
use crate::{onboarding_modal::ZedPredictModal, RateCompletionModal, RateCompletions};
pub fn init(cx: &mut App) {
cx.observe_new(move |workspace: &mut Workspace, _, _cx| {
workspace.register_action(|workspace, _: &RateCompletions, window, cx| {
if cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>() {
RateCompletionModal::toggle(workspace, window, cx);
}
});
workspace.register_action(
move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| {
if cx.has_flag::<PredictEditsFeatureFlag>() {
ZedPredictModal::toggle(
workspace,
workspace.user_store().clone(),
workspace.client().clone(),
workspace.app_state().fs.clone(),
window,
cx,
)
}
},
);
})
.detach();
feature_gate_predict_edits_rating_actions(cx);
}
fn feature_gate_predict_edits_rating_actions(cx: &mut App) {
let rate_completion_action_types = [TypeId::of::<RateCompletions>()];
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&rate_completion_action_types);
filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]);
});
cx.observe_flag::<PredictEditsRateCompletionsFeatureFlag, _>(move |is_enabled, cx| {
if is_enabled {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.show_action_types(rate_completion_action_types.iter());
});
} else {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&rate_completion_action_types);
});
}
})
.detach();
}
+210
View File
@@ -0,0 +1,210 @@
use regex::Regex;
pub fn is_license_eligible_for_data_collection(license: &str) -> bool {
// TODO: Include more licenses later (namely, Apache)
for pattern in [MIT_LICENSE_REGEX, ISC_LICENSE_REGEX] {
let regex = Regex::new(pattern.trim()).unwrap();
if regex.is_match(license.trim()) {
return true;
}
}
false
}
const MIT_LICENSE_REGEX: &str = r#"
^.*MIT License.*
Copyright.*?
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files \(the "Software"\), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software\.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE\.$
"#;
const ISC_LICENSE_REGEX: &str = r#"
^ISC License
Copyright.*?
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies\.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS\. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE\.$
"#;
#[cfg(test)]
mod tests {
use unindent::unindent;
use crate::is_license_eligible_for_data_collection;
#[test]
fn test_mit_positive_detection() {
let example_license = unindent(
r#"
MIT License
Copyright (c) 2024 John Doe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"#
.trim(),
);
assert!(is_license_eligible_for_data_collection(&example_license));
let example_license = unindent(
r#"
The MIT License (MIT)
Copyright (c) 2019 John Doe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"#
.trim(),
);
assert!(is_license_eligible_for_data_collection(&example_license));
}
#[test]
fn test_mit_negative_detection() {
let example_license = unindent(
r#"
MIT License
Copyright (c) 2024 John Doe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This project is dual licensed under the MIT License and the Apache License, Version 2.0.
"#
.trim(),
);
assert!(!is_license_eligible_for_data_collection(&example_license));
}
#[test]
fn test_isc_positive_detection() {
let example_license = unindent(
r#"
ISC License
Copyright (c) 2024, John Doe
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
"#
.trim(),
);
assert!(is_license_eligible_for_data_collection(&example_license));
}
#[test]
fn test_isc_negative_detection() {
let example_license = unindent(
r#"
ISC License
Copyright (c) 2024, John Doe
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
This project is dual licensed under the ISC License and the MIT License.
"#
.trim(),
);
assert!(!is_license_eligible_for_data_collection(&example_license));
}
}
+132
View File
@@ -0,0 +1,132 @@
use chrono::Utc;
use feature_flags::{FeatureFlagAppExt as _, PredictEditsFeatureFlag};
use gpui::Subscription;
use language::language_settings::{all_language_settings, InlineCompletionProvider};
use settings::SettingsStore;
use ui::{prelude::*, ButtonLike, Tooltip};
use util::ResultExt;
/// Prompts the user to try Zed's Edit Prediction feature
pub struct ZedPredictBanner {
dismissed: bool,
_subscription: Subscription,
}
impl ZedPredictBanner {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
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(|_, window, cx| {
window.dispatch_action(Box::new(zed_actions::OpenZedPredictOnboarding), 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)
}
}
+445
View File
@@ -0,0 +1,445 @@
use std::{sync::Arc, time::Duration};
use crate::{Zeta, ZED_PREDICT_DATA_COLLECTION_CHOICE};
use client::{Client, UserStore};
use db::kvp::KEY_VALUE_STORE;
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::*, Checkbox, TintColor, Tooltip};
use util::ResultExt;
use workspace::{notifications::NotifyTaskExt, ModalView, Workspace};
use worktree::Worktree;
/// Introduces user to Zed's Edit 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,
data_collection_expanded: bool,
data_collection_opted_in: bool,
worktrees: Vec<Entity<Worktree>>,
}
#[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 {
pub fn toggle(
workspace: &mut Workspace,
user_store: Entity<UserStore>,
client: Arc<Client>,
fs: Arc<dyn Fs>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let worktrees = workspace.visible_worktrees(cx).collect();
workspace.toggle_modal(window, cx, |_window, cx| Self {
user_store,
client,
fs,
focus_handle: cx.focus_handle(),
sign_in_status: SignInStatus::Idle,
terms_of_service: false,
data_collection_expanded: false,
data_collection_opted_in: false,
worktrees,
});
}
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 inline_completions_doc(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
cx.open_url("https://zed.dev/docs/configuring-zed#inline-completions");
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?;
let mut data_collection_opted_in = false;
this.update(&mut cx, |this, _cx| {
data_collection_opted_in = this.data_collection_opted_in;
})
.ok();
KEY_VALUE_STORE
.write_kvp(
ZED_PREDICT_DATA_COLLECTION_CHOICE.into(),
data_collection_opted_in.to_string(),
)
.await
.log_err();
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);
});
if this.worktrees.is_empty() {
cx.emit(DismissEvent);
return;
}
Zeta::register(None, this.client.clone(), this.user_store.clone(), cx);
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()
.id("zed predict tos")
.key_context("ZedPredictModal")
.w(px(440.))
.p_4()
.relative()
.gap_2()
.overflow_hidden()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::cancel))
.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_1()
.left_1p5()
.right_0()
.h(px(200.))
.child(
svg()
.path("icons/zed_predict_bg.svg")
.text_color(cx.theme().colors().icon_disabled)
.w(px(418.))
.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 instant edit predictions at every keystroke. Before setting Zed as your inline completions provider:",
SignInStatus::SignedIn => "Almost there! Ensure you:",
SignInStatus::Waiting => unreachable!(),
};
let accordion_icons = if self.data_collection_expanded {
(IconName::ChevronUp, IconName::ChevronDown)
} else {
(IconName::ChevronDown, IconName::ChevronUp)
};
fn label_item(label_text: impl Into<SharedString>) -> impl Element {
Label::new(label_text).color(Color::Muted).into_element()
}
fn info_item(label_text: impl Into<SharedString>) -> impl Element {
h_flex()
.gap_2()
.child(Icon::new(IconName::Check).size(IconSize::XSmall))
.child(label_item(label_text))
}
fn multiline_info_item<E1: Into<SharedString>, E2: IntoElement>(
first_line: E1,
second_line: E2,
) -> impl Element {
v_flex()
.child(info_item(first_line))
.child(div().pl_5().child(second_line))
}
base.child(Label::new(copy).color(Color::Muted))
.child(
h_flex()
.child(
Checkbox::new("tos-checkbox", self.terms_of_service.into())
.fill()
.label("Read and accept the")
.on_click(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()
.child(
h_flex()
.child(
Checkbox::new(
"training-data-checkbox",
self.data_collection_opted_in.into(),
)
.label("Optionally share training data (OSS-only).")
.fill()
.when(self.worktrees.is_empty(), |element| {
element.disabled(true).tooltip(move |window, cx| {
Tooltip::with_meta(
"No Project Open",
None,
"Open a project to enable this option.",
window,
cx,
)
})
})
.on_click(cx.listener(
move |this, state, _window, cx| {
this.data_collection_opted_in =
*state == ToggleState::Selected;
cx.notify()
},
)),
)
// TODO: show each worktree if more than 1
.child(
Button::new("learn-more", "Learn More")
.icon(accordion_icons.0)
.icon_size(IconSize::Indicator)
.icon_color(Color::Muted)
.on_click(cx.listener(|this, _, _, cx| {
this.data_collection_expanded =
!this.data_collection_expanded;
cx.notify()
})),
),
)
.when(self.data_collection_expanded, |element| {
element.child(
v_flex()
.mt_2()
.p_2()
.rounded_md()
.bg(cx.theme().colors().editor_background.opacity(0.5))
.border_1()
.border_color(cx.theme().colors().border_variant)
.child(
div().child(
Label::new("To improve edit predictions, help fine-tune Zed's model by sharing data from the open-source projects you work on.")
.mb_1()
)
)
.child(info_item(
"We ask this exclusively for open-source projects.",
))
.child(info_item(
"Zed automatically detects if your project is open-source.",
))
.child(info_item(
"This setting is valid for all OSS projects you open in Zed.",
))
.child(info_item("Toggle it anytime via the status bar menu."))
.child(multiline_info_item(
"Files that can contain sensitive data, like `.env`, are",
h_flex()
.child(label_item("excluded by default via the"))
.child(
Button::new("doc-link", "disabled_globs").on_click(
cx.listener(Self::inline_completions_doc),
),
)
.child(label_item("setting.")),
)),
)
}),
)
.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),
)
}
}
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::{Path, PathBuf};
use workspace::WorkspaceDb;
use db::sqlez_macros::sql;
use db::{define_connection, query};
define_connection!(
pub static ref DB: ZetaDb<WorkspaceDb> = &[
sql! (
CREATE TABLE zeta_preferences(
worktree_path BLOB NOT NULL PRIMARY KEY,
accepted_data_collection INTEGER
) STRICT;
),
];
);
impl ZetaDb {
query! {
pub fn get_all_data_collection_preferences() -> Result<Vec<(PathBuf, bool)>> {
SELECT worktree_path, accepted_data_collection FROM zeta_preferences
}
}
query! {
pub fn get_accepted_data_collection(worktree_path: &Path) -> Result<Option<bool>> {
SELECT accepted_data_collection FROM zeta_preferences
WHERE worktree_path = ?
}
}
query! {
pub async fn save_data_collection_choice(worktree_path: PathBuf, accepted_data_collection: bool) -> Result<()> {
INSERT INTO zeta_preferences
(worktree_path, accepted_data_collection)
VALUES
(?1, ?2)
ON CONFLICT (worktree_path) DO UPDATE SET
accepted_data_collection = ?2
}
}
query! {
pub async fn clear_all_zeta_preferences() -> Result<()> {
DELETE FROM zeta_preferences
}
}
}
+1 -37
View File
@@ -1,10 +1,8 @@
use crate::{CompletionDiffElement, InlineCompletion, InlineCompletionRating, Zeta};
use command_palette_hooks::CommandPaletteFilter;
use editor::Editor;
use feature_flags::{FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag};
use gpui::{actions, prelude::*, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable};
use language::language_settings;
use std::{any::TypeId, time::Duration};
use std::time::Duration;
use ui::{prelude::*, KeyBinding, List, ListItem, ListItemSpacing, Tooltip};
use workspace::{ModalView, Workspace};
@@ -21,40 +19,6 @@ actions!(
]
);
pub fn init(cx: &mut App) {
cx.observe_new(move |workspace: &mut Workspace, _, _cx| {
workspace.register_action(|workspace, _: &RateCompletions, window, cx| {
if cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>() {
RateCompletionModal::toggle(workspace, window, cx);
}
});
})
.detach();
feature_gate_predict_edits_rating_actions(cx);
}
fn feature_gate_predict_edits_rating_actions(cx: &mut App) {
let rate_completion_action_types = [TypeId::of::<RateCompletions>()];
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&rate_completion_action_types);
});
cx.observe_flag::<PredictEditsRateCompletionsFeatureFlag, _>(move |is_enabled, cx| {
if is_enabled {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.show_action_types(rate_completion_action_types.iter());
});
} else {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&rate_completion_action_types);
});
}
})
.detach();
}
pub struct RateCompletionModal {
zeta: Entity<Zeta>,
active_completion: Option<ActiveCompletion>,
+157 -239
View File
@@ -1,22 +1,26 @@
mod completion_diff_element;
mod persistence;
mod init;
mod license_detection;
mod onboarding_banner;
mod onboarding_modal;
mod rate_completion_modal;
pub(crate) use completion_diff_element::*;
use db::kvp::KEY_VALUE_STORE;
pub use init::*;
use inline_completion::DataCollectionState;
pub use license_detection::is_license_eligible_for_data_collection;
pub use onboarding_banner::*;
pub use rate_completion_modal::*;
use anyhow::{anyhow, Context as _, Result};
use arrayvec::ArrayVec;
use client::{Client, UserStore};
use collections::hash_map::Entry;
use collections::{HashMap, HashSet, VecDeque};
use feature_flags::FeatureFlagAppExt as _;
use futures::AsyncReadExt;
use gpui::{
actions, App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, Subscription, Task,
WeakEntity,
};
use http_client::{HttpClient, Method};
use language::{
@@ -24,33 +28,32 @@ use language::{
OffsetRangeExt, Point, ToOffset, ToPoint,
};
use language_models::LlmApiToken;
use postage::watch;
use rpc::{PredictEditsParams, PredictEditsResponse, EXPIRED_LLM_TOKEN_HEADER_NAME};
use settings::WorktreeId;
use std::{
borrow::Cow,
cmp, env,
cmp,
fmt::Write,
future::Future,
mem,
ops::Range,
path::{Path, PathBuf},
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use telemetry_events::InlineCompletionRating;
use util::ResultExt;
use uuid::Uuid;
use workspace::{
notifications::{simple_message_notification::MessageNotification, NotificationId},
Workspace,
};
use worktree::Worktree;
const CURSOR_MARKER: &'static str = "<|user_cursor_is_here|>";
const START_OF_FILE_MARKER: &'static str = "<|start_of_file|>";
const EDITABLE_REGION_START_MARKER: &'static str = "<|editable_region_start|>";
const EDITABLE_REGION_END_MARKER: &'static str = "<|editable_region_end|>";
const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1);
const ZED_PREDICT_DATA_COLLECTION_NEVER_ASK_AGAIN_KEY: &'static str =
"zed_predict_data_collection_never_ask_again";
const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice";
// TODO(mgsloan): more systematic way to choose or tune these fairly arbitrary constants?
@@ -206,11 +209,12 @@ pub struct Zeta {
registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
shown_completions: VecDeque<InlineCompletion>,
rated_completions: HashSet<InlineCompletionId>,
data_collection_preferences: DataCollectionPreferences,
data_collection_choice: Entity<DataCollectionChoice>,
llm_token: LlmApiToken,
_llm_token_subscription: Subscription,
tos_accepted: bool, // Terms of service accepted
_user_store_subscription: Subscription,
license_detection_watchers: HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
}
impl Zeta {
@@ -219,15 +223,28 @@ impl Zeta {
}
pub fn register(
worktree: Option<Entity<Worktree>>,
client: Arc<Client>,
user_store: Entity<UserStore>,
cx: &mut App,
) -> Entity<Self> {
Self::global(cx).unwrap_or_else(|| {
let this = Self::global(cx).unwrap_or_else(|| {
let model = cx.new(|cx| Self::new(client, user_store, cx));
cx.set_global(ZetaGlobal(model.clone()));
model
})
});
this.update(cx, move |this, cx| {
if let Some(worktree) = worktree {
worktree.update(cx, |worktree, cx| {
this.license_detection_watchers
.entry(worktree.id())
.or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(worktree, cx)));
});
}
});
this
}
pub fn clear_history(&mut self) {
@@ -236,13 +253,17 @@ impl Zeta {
fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
let refresh_llm_token_listener = language_models::RefreshLlmTokenListener::global(cx);
let data_collection_choice = Self::load_data_collection_choices();
let data_collection_choice = cx.new(|_| data_collection_choice);
Self {
client,
events: VecDeque::new(),
shown_completions: VecDeque::new(),
rated_completions: HashSet::default(),
registered_buffers: HashMap::default(),
data_collection_preferences: Self::load_data_collection_preferences(cx),
data_collection_choice,
llm_token: LlmApiToken::default(),
_llm_token_subscription: cx.subscribe(
&refresh_llm_token_listener,
@@ -271,6 +292,7 @@ impl Zeta {
_ => {}
}
}),
license_detection_watchers: HashMap::default(),
}
}
@@ -342,7 +364,7 @@ impl Zeta {
&mut self,
buffer: &Entity<Buffer>,
cursor: language::Anchor,
can_collect_data: bool,
data_collection_permission: bool,
cx: &mut Context<Self>,
perform_predict_edits: F,
) -> Task<Result<Option<InlineCompletion>>>
@@ -407,7 +429,7 @@ impl Zeta {
input_events: input_events.clone(),
input_excerpt: input_excerpt.clone(),
outline: Some(input_outline.clone()),
can_collect_data,
data_collection_permission,
};
let response = perform_predict_edits(client, llm_token, is_staff, body).await?;
@@ -587,13 +609,13 @@ and then another
&mut self,
buffer: &Entity<Buffer>,
position: language::Anchor,
can_collect_data: bool,
data_collection_permission: bool,
cx: &mut Context<Self>,
) -> Task<Result<Option<InlineCompletion>>> {
self.request_completion_impl(
buffer,
position,
can_collect_data,
data_collection_permission,
cx,
Self::perform_predict_edits,
)
@@ -903,84 +925,55 @@ and then another
new_snapshot
}
/// Creates a `Entity<DataCollectionChoice>` for each unique worktree abs path it sees.
pub fn data_collection_choice_at(
&mut self,
worktree_abs_path: PathBuf,
cx: &mut Context<Self>,
) -> Entity<DataCollectionChoice> {
match self
.data_collection_preferences
.per_worktree
.entry(worktree_abs_path)
{
Entry::Vacant(entry) => {
let choice = cx.new(|_| DataCollectionChoice::NotAnswered);
entry.insert(choice.clone());
choice
fn load_data_collection_choices() -> DataCollectionChoice {
let choice = KEY_VALUE_STORE
.read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE)
.log_err()
.flatten();
match choice.as_deref() {
Some("true") => DataCollectionChoice::Enabled,
Some("false") => DataCollectionChoice::Disabled,
Some(_) => {
log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'");
DataCollectionChoice::NotAnswered
}
Entry::Occupied(entry) => entry.get().clone(),
}
}
fn set_never_ask_again_for_data_collection(&mut self, cx: &mut Context<Self>) {
self.data_collection_preferences.never_ask_again = true;
// persist choice
db::write_and_log(cx, move || {
KEY_VALUE_STORE.write_kvp(
ZED_PREDICT_DATA_COLLECTION_NEVER_ASK_AGAIN_KEY.into(),
"true".to_string(),
)
});
}
fn load_data_collection_preferences(cx: &mut Context<Self>) -> DataCollectionPreferences {
if env::var("ZED_PREDICT_CLEAR_DATA_COLLECTION_PREFERENCES").is_ok() {
db::write_and_log(cx, move || async move {
KEY_VALUE_STORE
.delete_kvp(ZED_PREDICT_DATA_COLLECTION_NEVER_ASK_AGAIN_KEY.into())
.await
.log_err();
persistence::DB.clear_all_zeta_preferences().await
});
return DataCollectionPreferences::default();
}
let never_ask_again = KEY_VALUE_STORE
.read_kvp(ZED_PREDICT_DATA_COLLECTION_NEVER_ASK_AGAIN_KEY)
.log_err()
.flatten()
.map(|value| value == "true")
.unwrap_or(false);
let preferences_per_worktree = persistence::DB
.get_all_data_collection_preferences()
.log_err()
.into_iter()
.flatten()
.map(|(path, choice)| {
let choice = cx.new(|_| DataCollectionChoice::from(choice));
(path, choice)
})
.collect();
DataCollectionPreferences {
never_ask_again,
per_worktree: preferences_per_worktree,
None => DataCollectionChoice::NotAnswered,
}
}
}
#[derive(Default, Debug)]
struct DataCollectionPreferences {
/// Set when a user clicks on "Never Ask Again", can never be unset.
never_ask_again: bool,
/// The choices for each worktree.
///
/// This is filled when loading from database, or when querying if no matching path is found.
per_worktree: HashMap<PathBuf, Entity<DataCollectionChoice>>,
struct LicenseDetectionWatcher {
is_open_source_rx: watch::Receiver<bool>,
_is_open_source_task: Task<()>,
}
impl LicenseDetectionWatcher {
pub fn new(worktree: &Worktree, cx: &mut Context<Worktree>) -> Self {
let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::<bool>(false);
let loaded_file_fut = worktree.load_file(Path::new("LICENSE"), false, cx);
Self {
is_open_source_rx,
_is_open_source_task: cx.spawn(|_, _| async move {
// TODO: Don't display error if file not found
let Some(loaded_file) = loaded_file_fut.await.log_err() else {
return;
};
let is_loaded_file_open_source_thing: bool =
is_license_eligible_for_data_collection(&loaded_file.text);
*is_open_source_tx.borrow_mut() = is_loaded_file_open_source_thing;
}),
}
}
/// Answers false until we find out it's open source
pub fn is_open_source(&self) -> bool {
*self.is_open_source_rx.borrow()
}
}
fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(a: T1, b: T2) -> usize {
@@ -1308,7 +1301,7 @@ impl DataCollectionChoice {
}
}
pub fn toggle(self) -> DataCollectionChoice {
pub fn toggle(&self) -> DataCollectionChoice {
match self {
Self::Enabled => Self::Disabled,
Self::Disabled => Self::Enabled,
@@ -1326,87 +1319,93 @@ impl From<bool> for DataCollectionChoice {
}
}
pub struct ZetaInlineCompletionProvider {
zeta: Entity<Zeta>,
pending_completions: ArrayVec<PendingCompletion, 2>,
next_pending_completion_id: usize,
current_completion: Option<CurrentInlineCompletion>,
data_collection: Option<ProviderDataCollection>,
}
pub struct ProviderDataCollection {
workspace: WeakEntity<Workspace>,
worktree_root_path: PathBuf,
choice: Entity<DataCollectionChoice>,
/// When set to None, data collection is not possible in the provider buffer
choice: Option<Entity<DataCollectionChoice>>,
license_detection_watcher: Option<Rc<LicenseDetectionWatcher>>,
}
impl ProviderDataCollection {
pub fn new(
zeta: Entity<Zeta>,
workspace: Option<Entity<Workspace>>,
buffer: Option<Entity<Buffer>>,
cx: &mut App,
) -> Option<ProviderDataCollection> {
let workspace = workspace?;
let worktree_root_path = buffer?.update(cx, |buffer, cx| {
let file = buffer.file()?;
pub fn new(zeta: Entity<Zeta>, buffer: Option<Entity<Buffer>>, cx: &mut App) -> Self {
let choice_and_watcher = buffer.and_then(|buffer| {
let file = buffer.read(cx).file()?;
if !file.is_local() || file.is_private() {
return None;
}
workspace.update(cx, |workspace, cx| {
Some(
workspace
.absolute_path_of_worktree(file.worktree_id(cx), cx)?
.to_path_buf(),
let zeta = zeta.read(cx);
let choice = zeta.data_collection_choice.clone();
// Unwrap safety: there should be a watcher for each worktree
let license_detection_watcher = zeta
.license_detection_watchers
.get(&file.worktree_id(cx))
.cloned()?;
Some((choice, license_detection_watcher))
});
if let Some((choice, watcher)) = choice_and_watcher {
ProviderDataCollection {
choice: Some(choice),
license_detection_watcher: Some(watcher),
}
} else {
ProviderDataCollection {
choice: None,
license_detection_watcher: None,
}
}
}
pub fn data_collection_permission(&self, cx: &App) -> bool {
self.choice
.as_ref()
.is_some_and(|choice| choice.read(cx).is_enabled())
&& self
.license_detection_watcher
.as_ref()
.is_some_and(|watcher| watcher.is_open_source())
}
pub fn toggle(&mut self, cx: &mut App) {
if let Some(choice) = self.choice.as_mut() {
let new_choice = choice.update(cx, |choice, _cx| {
let new_choice = choice.toggle();
*choice = new_choice;
new_choice
});
db::write_and_log(cx, move || {
KEY_VALUE_STORE.write_kvp(
ZED_PREDICT_DATA_COLLECTION_CHOICE.into(),
new_choice.is_enabled().to_string(),
)
})
})?;
let choice = zeta.update(cx, |zeta, cx| {
zeta.data_collection_choice_at(worktree_root_path.clone(), cx)
});
Some(ProviderDataCollection {
workspace: workspace.downgrade(),
worktree_root_path,
choice,
})
});
}
}
}
fn set_choice(&mut self, choice: DataCollectionChoice, cx: &mut App) {
self.choice.update(cx, |this, _| *this = choice);
let worktree_root_path = self.worktree_root_path.clone();
db::write_and_log(cx, move || {
persistence::DB.save_data_collection_choice(worktree_root_path, choice.is_enabled())
});
}
fn toggle_choice(&mut self, cx: &mut App) {
self.set_choice(self.choice.read(cx).toggle(), cx);
}
pub struct ZetaInlineCompletionProvider {
zeta: Entity<Zeta>,
pending_completions: ArrayVec<PendingCompletion, 2>,
next_pending_completion_id: usize,
current_completion: Option<CurrentInlineCompletion>,
/// None if this is entirely disabled for this provider
provider_data_collection: ProviderDataCollection,
}
impl ZetaInlineCompletionProvider {
pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(8);
pub fn new(zeta: Entity<Zeta>, data_collection: Option<ProviderDataCollection>) -> Self {
pub fn new(zeta: Entity<Zeta>, provider_data_collection: ProviderDataCollection) -> Self {
Self {
zeta,
pending_completions: ArrayVec::new(),
next_pending_completion_id: 0,
current_completion: None,
data_collection,
}
}
fn set_data_collection_choice(&mut self, choice: DataCollectionChoice, cx: &mut App) {
if let Some(data_collection) = self.data_collection.as_mut() {
data_collection.set_choice(choice, cx);
provider_data_collection,
}
}
}
@@ -1433,11 +1432,7 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
}
fn data_collection_state(&self, cx: &App) -> DataCollectionState {
let Some(data_collection) = self.data_collection.as_ref() else {
return DataCollectionState::Unknown;
};
if data_collection.choice.read(cx).is_enabled() {
if self.provider_data_collection.data_collection_permission(cx) {
DataCollectionState::Enabled
} else {
DataCollectionState::Disabled
@@ -1445,9 +1440,7 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
}
fn toggle_data_collection(&mut self, cx: &mut App) {
if let Some(data_collection) = self.data_collection.as_mut() {
data_collection.toggle_choice(cx);
}
self.provider_data_collection.toggle(cx);
}
fn is_enabled(
@@ -1495,12 +1488,8 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
let pending_completion_id = self.next_pending_completion_id;
self.next_pending_completion_id += 1;
let can_collect_data = self
.data_collection
.as_ref()
.map_or(false, |data_collection| {
data_collection.choice.read(cx).is_enabled()
});
let data_collection_permission =
self.provider_data_collection.data_collection_permission(cx);
let task = cx.spawn(|this, mut cx| async move {
if debounce {
@@ -1509,7 +1498,7 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
let completion_request = this.update(&mut cx, |this, cx| {
this.zeta.update(cx, |zeta, cx| {
zeta.request_completion(&buffer, position, can_collect_data, cx)
zeta.request_completion(&buffer, position, data_collection_permission, cx)
})
});
@@ -1596,79 +1585,8 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
// Right now we don't support cycling.
}
fn accept(&mut self, cx: &mut Context<Self>) {
fn accept(&mut self, _cx: &mut Context<Self>) {
self.pending_completions.clear();
let Some(data_collection) = self.data_collection.as_mut() else {
return;
};
if data_collection.choice.read(cx).is_answered()
|| self
.zeta
.read(cx)
.data_collection_preferences
.never_ask_again
{
return;
}
struct ZetaDataCollectionNotification;
let notification_id = NotificationId::unique::<ZetaDataCollectionNotification>();
const DATA_COLLECTION_INFO_URL: &str = "https://zed.dev/terms-of-service"; // TODO: Replace for a link that's dedicated to Edit Predictions data collection
let this = cx.entity();
data_collection
.workspace
.update(cx, |workspace, cx| {
workspace.show_notification(notification_id, cx, |cx| {
let zeta = self.zeta.clone();
cx.new(move |_cx| {
let message =
"To allow Zed to suggest better edits, turn on data collection. You \
can turn off at any time via the status bar menu.";
MessageNotification::new(message)
.with_title("Per-Project Data Collection Program")
.show_close_button(false)
.with_click_message("Turn On")
.on_click({
let this = this.clone();
move |_window, cx| {
this.update(cx, |this, cx| {
this.set_data_collection_choice(
DataCollectionChoice::Enabled,
cx,
)
});
}
})
.with_secondary_click_message("Turn Off")
.on_secondary_click({
move |_window, cx| {
this.update(cx, |this, cx| {
this.set_data_collection_choice(
DataCollectionChoice::Disabled,
cx,
)
});
}
})
.with_tertiary_click_message("Never Ask Again")
.on_tertiary_click({
move |_window, cx| {
zeta.update(cx, |zeta, cx| {
zeta.set_never_ask_again_for_data_collection(cx);
});
}
})
.more_info_message("Learn More")
.more_info_url(DATA_COLLECTION_INFO_URL)
})
});
})
.log_err();
}
fn discard(&mut self, _cx: &mut Context<Self>) {