1254 lines
44 KiB
Rust
1254 lines
44 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use ::settings::{Setting, ToggleableSetting};
|
|
use galaxy_core::channel::ChannelState;
|
|
use galaxy_core::context_flag::ContextFlag;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_core::ui::icons::Icon;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use galaxy_server_client::iap::{IapCredentialsState, IapManager, IapManagerEvent};
|
|
use galaxyui::assets::asset_cache::AssetSource;
|
|
use galaxyui::elements::{
|
|
Align, Border, CacheOption, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
|
Element, Empty, Flex, Image, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
|
Radius, Shrinkable, Text,
|
|
};
|
|
use galaxyui::fonts::Weight;
|
|
use galaxyui::keymap::ContextPredicate;
|
|
use galaxyui::platform::Cursor;
|
|
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
|
|
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
|
use galaxyui::ui_components::switch::SwitchStateHandle;
|
|
use galaxyui::{
|
|
id, Action, AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
|
ViewContext, ViewHandle,
|
|
};
|
|
use lazy_static::lazy_static;
|
|
use pathfinder_color::ColorU;
|
|
use pathfinder_geometry::vector::vec2f;
|
|
|
|
use super::settings_page::{
|
|
render_body_item, render_customer_type_badge, AdditionalInfo, LocalOnlyIconState, MatchData,
|
|
PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState,
|
|
HEADER_PADDING,
|
|
};
|
|
use super::{flags, SettingsAction, SettingsSection, ToggleSettingActionPair};
|
|
use crate::appearance::Appearance;
|
|
use crate::auth::auth_manager::{AuthManager, LoginGatedFeature};
|
|
use crate::auth::auth_state::AuthState;
|
|
use crate::auth::auth_view_modal::AuthViewVariant;
|
|
use crate::auth::{AuthStateProvider, UserUid};
|
|
use crate::autoupdate::{self, AutoupdateStage, AutoupdateState};
|
|
use crate::server::ids::ServerId;
|
|
use crate::settings::cloud_preferences::CloudPreferencesSettings;
|
|
use crate::workspace::WorkspaceAction;
|
|
use crate::workspaces::update_manager::TeamUpdateManager;
|
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
|
use crate::workspaces::workspace::CustomerType;
|
|
use crate::{report_if_error, send_telemetry_from_ctx, TelemetryEvent};
|
|
|
|
const PHOTO_SIZE: f32 = 40.;
|
|
const REFERRAL_CTA: &str = "Earn rewards by sharing Warp with friends & colleagues";
|
|
const REGULAR_TEXT_FONT_SIZE: f32 = 12.;
|
|
const VERTICAL_MARGIN: f32 = 24.;
|
|
const LOG_OUT_TEXT: &str = "Log out";
|
|
lazy_static! {
|
|
static ref SETTINGS_SYNC_BINDINGS_ADDED: Arc<Mutex<bool>> = Default::default();
|
|
}
|
|
|
|
pub fn init_actions_from_parent_view<T: Action + Clone>(
|
|
app: &mut AppContext,
|
|
context: &ContextPredicate,
|
|
builder: fn(SettingsAction) -> T,
|
|
) {
|
|
let mut toggle_binding_pairs = Vec::new();
|
|
maybe_add_settings_sync_toggle_binding(app, context, builder, &mut toggle_binding_pairs);
|
|
|
|
// Add other bindings here in the future.
|
|
|
|
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app);
|
|
}
|
|
|
|
fn maybe_add_settings_sync_toggle_binding<T: Action + Clone>(
|
|
app: &mut AppContext,
|
|
context: &ContextPredicate,
|
|
builder: fn(SettingsAction) -> T,
|
|
toggle_binding_pairs: &mut Vec<ToggleSettingActionPair<T>>,
|
|
) {
|
|
let mut lock = SETTINGS_SYNC_BINDINGS_ADDED
|
|
.lock()
|
|
.expect("settings sync bindings lock poisoned");
|
|
if !*lock {
|
|
*lock = true;
|
|
toggle_binding_pairs.push(
|
|
ToggleSettingActionPair::new(
|
|
"settings sync",
|
|
builder(SettingsAction::MainPageToggle(
|
|
MainPageAction::ToggleSettingsSync,
|
|
)),
|
|
context,
|
|
flags::SETTINGS_SYNC_FLAG,
|
|
)
|
|
.is_supported_on_current_platform(
|
|
CloudPreferencesSettings::as_ref(app)
|
|
.settings_sync_enabled
|
|
.is_supported_on_current_platform(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn handle_experiment_change(app: &mut AppContext) {
|
|
let mut toggle_binding_pairs: Vec<ToggleSettingActionPair<WorkspaceAction>> = Vec::new();
|
|
maybe_add_settings_sync_toggle_binding(
|
|
app,
|
|
&id!("Workspace"),
|
|
WorkspaceAction::DispatchToSettingsTab,
|
|
&mut toggle_binding_pairs,
|
|
);
|
|
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app);
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum MainPageAction {
|
|
Relaunch,
|
|
DownloadUpdate,
|
|
CheckForUpdate,
|
|
ToggleSettingsSync,
|
|
Upgrade {
|
|
team_uid: Option<ServerId>,
|
|
user_id: UserUid,
|
|
},
|
|
GenerateStripeBillingPortalLink {
|
|
team_uid: ServerId,
|
|
},
|
|
SignupAnonymousUser,
|
|
OpenUrl(String),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
RefreshIapCredentials,
|
|
}
|
|
|
|
impl MainPageAction {
|
|
fn blocked_for_anonymous_user(&self) -> bool {
|
|
use MainPageAction::*;
|
|
matches!(
|
|
self,
|
|
Upgrade { .. } | GenerateStripeBillingPortalLink { .. } | ToggleSettingsSync,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<&MainPageAction> for LoginGatedFeature {
|
|
fn from(val: &MainPageAction) -> LoginGatedFeature {
|
|
match val {
|
|
MainPageAction::Upgrade { .. } => "Upgrade Plan",
|
|
MainPageAction::GenerateStripeBillingPortalLink { .. } => {
|
|
"Generate Stripe Billing Portal Link"
|
|
}
|
|
MainPageAction::ToggleSettingsSync => "Toggle Settings Sync",
|
|
_ => "Unknown reason",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum MainSettingsPageEvent {
|
|
CheckForUpdate,
|
|
#[allow(dead_code)]
|
|
OpenGalaxyDrive,
|
|
SignupAnonymousUser,
|
|
}
|
|
|
|
pub struct MainSettingsPageView {
|
|
page: PageType<Self>,
|
|
auth_state: Arc<AuthState>,
|
|
}
|
|
|
|
impl Entity for MainSettingsPageView {
|
|
type Event = MainSettingsPageEvent;
|
|
}
|
|
|
|
impl TypedActionView for MainSettingsPageView {
|
|
type Action = MainPageAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
// Block anonymous users from upgrading
|
|
if AuthStateProvider::as_ref(ctx)
|
|
.get()
|
|
.is_anonymous_or_logged_out()
|
|
&& action.blocked_for_anonymous_user()
|
|
{
|
|
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
|
auth_manager.attempt_login_gated_feature(
|
|
action.into(),
|
|
AuthViewVariant::RequireLoginCloseable,
|
|
ctx,
|
|
)
|
|
});
|
|
return;
|
|
}
|
|
|
|
match action {
|
|
MainPageAction::Relaunch => {
|
|
autoupdate::initiate_relaunch_for_update(ctx);
|
|
}
|
|
MainPageAction::DownloadUpdate => {
|
|
autoupdate::manually_download_new_version(ctx);
|
|
}
|
|
MainPageAction::CheckForUpdate => {
|
|
ctx.emit(MainSettingsPageEvent::CheckForUpdate);
|
|
ctx.notify();
|
|
}
|
|
MainPageAction::ToggleSettingsSync => {
|
|
let new_value =
|
|
CloudPreferencesSettings::handle(ctx).update(ctx, |prefs_settings, ctx| {
|
|
report_if_error!(prefs_settings
|
|
.settings_sync_enabled
|
|
.toggle_and_save_value(ctx));
|
|
*prefs_settings.settings_sync_enabled
|
|
});
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::ToggleSettingsSync {
|
|
is_settings_sync_enabled: new_value,
|
|
},
|
|
ctx
|
|
);
|
|
ctx.notify();
|
|
}
|
|
MainPageAction::Upgrade { team_uid, user_id } => match team_uid {
|
|
Some(team_uid) => {
|
|
ctx.open_url(&UserWorkspaces::upgrade_link_for_team(*team_uid));
|
|
}
|
|
None => {
|
|
ctx.open_url(&UserWorkspaces::upgrade_link(*user_id));
|
|
}
|
|
},
|
|
MainPageAction::GenerateStripeBillingPortalLink { team_uid } => {
|
|
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
|
user_workspaces.generate_stripe_billing_portal_link(*team_uid, ctx);
|
|
});
|
|
}
|
|
MainPageAction::SignupAnonymousUser => {
|
|
ctx.emit(MainSettingsPageEvent::SignupAnonymousUser);
|
|
}
|
|
MainPageAction::OpenUrl(url) => {
|
|
ctx.open_url(url);
|
|
}
|
|
#[cfg(not(target_family = "wasm"))]
|
|
MainPageAction::RefreshIapCredentials => {
|
|
IapManager::handle(ctx).update(ctx, |manager, ctx| manager.start_refresh(ctx));
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl View for MainSettingsPageView {
|
|
fn ui_name() -> &'static str {
|
|
"MainSettingsPage"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
self.page.render(self, app)
|
|
}
|
|
}
|
|
|
|
impl MainSettingsPageView {
|
|
pub fn new(ctx: &mut ViewContext<MainSettingsPageView>) -> Self {
|
|
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
|
|
|
let autoupdate_state_handle = AutoupdateState::handle(ctx);
|
|
ctx.observe(
|
|
&autoupdate_state_handle,
|
|
Self::handle_autoupdate_state_change,
|
|
);
|
|
|
|
ctx.subscribe_to_model(&CloudPreferencesSettings::handle(ctx), |_, _, _, ctx| {
|
|
ctx.notify();
|
|
});
|
|
|
|
let auth_manager_handle = AuthManager::handle(ctx);
|
|
ctx.subscribe_to_model(&auth_manager_handle, |_, _, _, ctx| {
|
|
ctx.notify();
|
|
});
|
|
|
|
let mut widgets: Vec<Box<dyn SettingsWidget<View = Self>>> = vec![
|
|
Box::new(AccountWidget::default()),
|
|
Box::new(DividerWidget {}),
|
|
];
|
|
|
|
widgets.push(Box::new(SettingsSyncWidget::default()));
|
|
|
|
widgets.push(Box::new(EarnRewardsWidget::default()));
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
if IapManager::as_ref(ctx).is_enabled() {
|
|
widgets.push(Box::new(IapCredentialsWidget::default()));
|
|
let iap_manager_handle = IapManager::handle(ctx);
|
|
ctx.subscribe_to_model(&iap_manager_handle, |_, _, e, ctx| {
|
|
if matches!(e, IapManagerEvent::StateChanged) {
|
|
ctx.notify();
|
|
}
|
|
})
|
|
}
|
|
|
|
if ChannelState::app_version().is_some() {
|
|
widgets.push(Box::new(VersionInfoWidget::default()));
|
|
}
|
|
|
|
widgets.push(Box::new(LogoutWidget::default()));
|
|
|
|
let page = PageType::new_uncategorized(widgets, Some("Account"));
|
|
|
|
MainSettingsPageView { page, auth_state }
|
|
}
|
|
|
|
fn handle_autoupdate_state_change(
|
|
&mut self,
|
|
_: ModelHandle<AutoupdateState>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AccountWidgetStateHandles {
|
|
upgrade_link: MouseStateHandle,
|
|
anonymous_user_sign_up_button: MouseStateHandle,
|
|
enterprise_contact_us_link: MouseStateHandle,
|
|
stripe_billing_portal_link: MouseStateHandle,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AccountWidget {
|
|
ui_state_handles: AccountWidgetStateHandles,
|
|
}
|
|
|
|
impl AccountWidget {
|
|
fn render_anonymous_account_info(
|
|
&self,
|
|
auth_state: &AuthState,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let button_styles = UiComponentStyles {
|
|
font_size: Some(14.),
|
|
font_weight: Some(Weight::Semibold),
|
|
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
|
padding: Some(Coords {
|
|
top: 12.,
|
|
bottom: 12.,
|
|
left: 40.,
|
|
right: 40.,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let user_info = appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Accent,
|
|
self.ui_state_handles.anonymous_user_sign_up_button.clone(),
|
|
)
|
|
.with_style(button_styles)
|
|
.with_text_label("Sign up".to_owned())
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(MainPageAction::SignupAnonymousUser);
|
|
})
|
|
.finish();
|
|
|
|
let mut plan_info = Flex::column()
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::End);
|
|
let current_user_id = auth_state.user_id().unwrap_or_default();
|
|
|
|
plan_info.add_child(render_customer_type_badge(appearance, "Free".into()));
|
|
plan_info.add_child(
|
|
Container::new(
|
|
appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Link,
|
|
self.ui_state_handles.upgrade_link.clone(),
|
|
)
|
|
.with_text_and_icon_label(
|
|
TextAndIcon::new(
|
|
TextAndIconAlignment::IconFirst,
|
|
"Compare plans",
|
|
Icon::CoinsStacked.to_galaxyui_icon(appearance.theme().accent()),
|
|
MainAxisSize::Min,
|
|
MainAxisAlignment::Center,
|
|
vec2f(14., 14.),
|
|
)
|
|
.with_inner_padding(4.),
|
|
)
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(MainPageAction::Upgrade {
|
|
team_uid: None,
|
|
user_id: current_user_id,
|
|
});
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_margin_top(8.)
|
|
.finish(),
|
|
);
|
|
|
|
Flex::row()
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.0,
|
|
Flex::row()
|
|
.with_child(user_info)
|
|
.with_main_axis_alignment(MainAxisAlignment::Start)
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_child(Align::new(plan_info.finish()).right().finish())
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.finish()
|
|
}
|
|
|
|
fn render_account_info(
|
|
&self,
|
|
profile_image_source: Option<&AssetSource>,
|
|
auth_state: &AuthState,
|
|
app: &AppContext,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let mut user_info = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
if let Some(profile_image_source) = profile_image_source {
|
|
// Only continue if profile_image_source is a source with a non empty url/path
|
|
if matches!(profile_image_source, AssetSource::Async { ref id, .. } if !id.key().is_empty())
|
|
|| matches!(profile_image_source, AssetSource::Bundled { path, .. } if !path.is_empty())
|
|
|| matches!(profile_image_source, AssetSource::LocalFile { path, .. } if !path.is_empty())
|
|
{
|
|
let photo = Image::new(profile_image_source.clone(), CacheOption::BySize)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
|
|
user_info.add_child(
|
|
Container::new(
|
|
ConstrainedBox::new(photo.finish())
|
|
.with_height(PHOTO_SIZE)
|
|
.with_width(PHOTO_SIZE)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(HEADER_PADDING)
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
|
|
let display_name = auth_state.username_for_display().map(|screen_name| {
|
|
let email = auth_state.user_email();
|
|
match email {
|
|
Some(email) => {
|
|
if !screen_name.is_empty() && screen_name != email {
|
|
Flex::column()
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Text::new_inline(screen_name, appearance.ui_font_family(), 16.)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
appearance
|
|
.ui_builder()
|
|
.paragraph(email)
|
|
.with_style(UiComponentStyles {
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.active_ui_text_color()
|
|
.with_opacity(60)
|
|
.into(),
|
|
),
|
|
font_size: Some(REGULAR_TEXT_FONT_SIZE),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.finish()
|
|
} else {
|
|
Text::new_inline(email, appearance.ui_font_family(), 16.)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish()
|
|
}
|
|
}
|
|
_ => Text::new_inline(screen_name, appearance.ui_font_family(), 16.)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
}
|
|
});
|
|
|
|
if let Some(display_name) = display_name {
|
|
user_info.add_child(display_name);
|
|
}
|
|
|
|
let mut plan_info = Flex::column()
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::End);
|
|
let current_user_id = auth_state.user_id().unwrap_or_default();
|
|
let workspaces = UserWorkspaces::as_ref(app);
|
|
if let Some(team) = workspaces.current_team() {
|
|
if team.billing_metadata.customer_type != CustomerType::Unknown {
|
|
plan_info.add_child(render_customer_type_badge(
|
|
appearance,
|
|
team.billing_metadata.customer_type.to_display_string(),
|
|
));
|
|
}
|
|
|
|
let current_user_email = auth_state.user_email().unwrap_or_default();
|
|
let has_admin_permissions = team.has_admin_permissions(¤t_user_email);
|
|
if has_admin_permissions {
|
|
if team.billing_metadata.customer_type == CustomerType::Enterprise {
|
|
plan_info.add_child(
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Contact support".into(),
|
|
Some("mailto:support@warp.dev".into()),
|
|
None,
|
|
self.ui_state_handles.enterprise_contact_us_link.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.with_margin_top(8.)
|
|
.finish(),
|
|
);
|
|
} else {
|
|
if team.has_billing_history {
|
|
let team_uid = team.uid;
|
|
plan_info.add_child(
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Manage billing".into(),
|
|
None,
|
|
Some(Box::new(move |ctx| {
|
|
ctx.dispatch_typed_action(
|
|
MainPageAction::GenerateStripeBillingPortalLink {
|
|
team_uid,
|
|
},
|
|
);
|
|
})),
|
|
self.ui_state_handles.stripe_billing_portal_link.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.with_margin_top(8.)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// If the team is upgradeable to self-serve tier, show them the upgrade link.
|
|
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
|
|
let description = match team.billing_metadata.customer_type {
|
|
CustomerType::Prosumer => "Upgrade to Turbo plan",
|
|
CustomerType::Turbo => "Upgrade to Lightspeed plan",
|
|
_ => "Compare plans",
|
|
};
|
|
let team_uid = team.uid;
|
|
plan_info.add_child(
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
description.into(),
|
|
None,
|
|
Some(Box::new(move |ctx| {
|
|
ctx.dispatch_typed_action(MainPageAction::Upgrade {
|
|
team_uid: Some(team_uid),
|
|
user_id: current_user_id,
|
|
});
|
|
})),
|
|
self.ui_state_handles.upgrade_link.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.with_margin_top(8.)
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let plan_badge_child = render_customer_type_badge(appearance, "Free".into());
|
|
plan_info.add_child(plan_badge_child);
|
|
|
|
plan_info.add_child(
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Compare plans".into(),
|
|
None,
|
|
Some(Box::new(move |ctx| {
|
|
ctx.dispatch_typed_action(MainPageAction::Upgrade {
|
|
team_uid: None,
|
|
user_id: current_user_id,
|
|
});
|
|
})),
|
|
self.ui_state_handles.upgrade_link.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.with_margin_top(8.)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
let mut row = Flex::row()
|
|
.with_child(
|
|
Shrinkable::new(1.0, Align::new(user_info.finish()).left().finish()).finish(),
|
|
)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start);
|
|
|
|
if !FeatureFlag::UsageBasedPricing.is_enabled() {
|
|
row.add_child(Align::new(plan_info.finish()).right().finish());
|
|
}
|
|
|
|
row.finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsWidget for AccountWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"account sign up"
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
view: &Self::View,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let account_info = if view.auth_state.is_anonymous_or_logged_out() {
|
|
self.render_anonymous_account_info(view.auth_state.as_ref(), appearance)
|
|
} else {
|
|
let profile_image_source = view.auth_state.user_photo_url().map(|url| {
|
|
asset_cache::url_source_with_persistence(url, &galaxy_core::paths::cache_dir())
|
|
});
|
|
self.render_account_info(
|
|
profile_image_source.as_ref(),
|
|
view.auth_state.as_ref(),
|
|
app,
|
|
appearance,
|
|
)
|
|
};
|
|
|
|
Flex::column()
|
|
.with_child(
|
|
Container::new(account_info)
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish(),
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
struct DividerWidget {}
|
|
|
|
impl SettingsWidget for DividerWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
""
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
_app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
Container::new(
|
|
Container::new(Empty::new().finish())
|
|
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
|
|
.finish(),
|
|
)
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SettingsSyncWidget {
|
|
tooltip_state: MouseStateHandle,
|
|
switch_state: SwitchStateHandle,
|
|
}
|
|
|
|
impl SettingsWidget for SettingsSyncWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"settings sync"
|
|
}
|
|
|
|
fn should_render(&self, app: &AppContext) -> bool {
|
|
!AuthStateProvider::as_ref(app)
|
|
.get()
|
|
.is_anonymous_or_logged_out()
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let preferences_settings = CloudPreferencesSettings::as_ref(app);
|
|
|
|
let label_info = AdditionalInfo {
|
|
mouse_state: self.tooltip_state.clone(),
|
|
on_click_action: Some(MainPageAction::OpenUrl(
|
|
"https://docs.warp.dev/terminal/more-features/settings-sync".into(),
|
|
)),
|
|
secondary_text: None,
|
|
tooltip_override_text: None,
|
|
};
|
|
|
|
Container::new(render_body_item::<MainPageAction>(
|
|
"Settings sync".to_string(),
|
|
Some(label_info),
|
|
// Cloud prefs are always synced, so no need to show the local-only icon.
|
|
LocalOnlyIconState::Hidden,
|
|
ToggleState::Enabled,
|
|
appearance,
|
|
appearance
|
|
.ui_builder()
|
|
.switch(self.switch_state.clone())
|
|
.check(*preferences_settings.settings_sync_enabled.value())
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(MainPageAction::ToggleSettingsSync)
|
|
})
|
|
.finish(),
|
|
None,
|
|
))
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct EarnRewardsWidget {
|
|
refer_link_mouse_handle: MouseStateHandle,
|
|
}
|
|
|
|
impl EarnRewardsWidget {
|
|
fn render_row(
|
|
&self,
|
|
appearance: &Appearance,
|
|
label: &str,
|
|
right_child: Box<dyn Element>,
|
|
) -> Box<dyn Element> {
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.0,
|
|
Align::new(
|
|
Text::new_inline(
|
|
label.to_string(),
|
|
appearance.ui_font_family(),
|
|
REGULAR_TEXT_FONT_SIZE,
|
|
)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_child(right_child)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsWidget for EarnRewardsWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"earn rewards referral share friends"
|
|
}
|
|
|
|
fn should_render(&self, app: &AppContext) -> bool {
|
|
!AuthStateProvider::as_ref(app)
|
|
.get()
|
|
.is_anonymous_or_logged_out()
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
_app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
Container::new(
|
|
self.render_row(
|
|
appearance,
|
|
REFERRAL_CTA,
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Refer a friend".into(),
|
|
None,
|
|
Some(Box::new(move |ctx| {
|
|
ctx.dispatch_typed_action(WorkspaceAction::ShowReferralSettingsPage);
|
|
})),
|
|
self.refer_link_mouse_handle.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.finish(),
|
|
),
|
|
)
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct VersionInfoWidget {
|
|
copy_version_button_mouse_state: MouseStateHandle,
|
|
version_info_cta_link_mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl VersionInfoWidget {
|
|
fn render_version_info(
|
|
&self,
|
|
version: &'static str,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let faded_text_color = appearance
|
|
.theme()
|
|
.active_ui_text_color()
|
|
.with_opacity(60)
|
|
.into();
|
|
struct StatusContent {
|
|
text: &'static str,
|
|
color: ColorU,
|
|
}
|
|
struct CallToActionContent {
|
|
text: &'static str,
|
|
action: MainPageAction,
|
|
}
|
|
|
|
let (status_content, call_to_action_content) =
|
|
if ContextFlag::PromptForVersionUpdates.is_enabled() {
|
|
let ansi_red: ColorU = appearance.theme().terminal_colors().bright.red.into();
|
|
match autoupdate::get_update_state(app) {
|
|
AutoupdateStage::NoUpdateAvailable => (
|
|
Some(StatusContent {
|
|
text: "Up to date",
|
|
color: faded_text_color,
|
|
}),
|
|
Some(CallToActionContent {
|
|
text: "Check for updates",
|
|
action: MainPageAction::CheckForUpdate,
|
|
}),
|
|
),
|
|
AutoupdateStage::CheckingForUpdate => (
|
|
Some(StatusContent {
|
|
text: "checking for update...",
|
|
color: faded_text_color,
|
|
}),
|
|
None,
|
|
),
|
|
AutoupdateStage::DownloadingUpdate => (
|
|
Some(StatusContent {
|
|
text: "downloading update...",
|
|
color: faded_text_color,
|
|
}),
|
|
None,
|
|
),
|
|
AutoupdateStage::UpdateReady { .. } => (
|
|
Some(StatusContent {
|
|
text: "Update available",
|
|
color: ansi_red,
|
|
}),
|
|
Some(CallToActionContent {
|
|
text: "Relaunch Warp",
|
|
action: MainPageAction::Relaunch,
|
|
}),
|
|
),
|
|
AutoupdateStage::Updating { .. } => (
|
|
Some(StatusContent {
|
|
text: "Updating...",
|
|
color: faded_text_color,
|
|
}),
|
|
None,
|
|
),
|
|
AutoupdateStage::UpdatedPendingRestart { .. } => (
|
|
Some(StatusContent {
|
|
text: "Installed update",
|
|
color: faded_text_color,
|
|
}),
|
|
Some(CallToActionContent {
|
|
text: "Relaunch Warp",
|
|
action: MainPageAction::Relaunch,
|
|
}),
|
|
),
|
|
AutoupdateStage::UnableToUpdateToNewVersion { .. } => (
|
|
Some(StatusContent {
|
|
text: "A new version of Warp is available but can't be installed",
|
|
color: ansi_red,
|
|
}),
|
|
Some(CallToActionContent {
|
|
text: "Update Warp manually",
|
|
// note: the handler for this action is a no-op
|
|
action: MainPageAction::DownloadUpdate,
|
|
}),
|
|
),
|
|
AutoupdateStage::UnableToLaunchNewVersion { .. } => (
|
|
Some(StatusContent {
|
|
text: "A new version of Warp is installed but can't be launched.",
|
|
color: ansi_red,
|
|
}),
|
|
Some(CallToActionContent {
|
|
text: "Update Warp manually",
|
|
// note: the handler for this action is a no-op
|
|
action: MainPageAction::DownloadUpdate,
|
|
}),
|
|
),
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let mut first_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.0,
|
|
Align::new(
|
|
Text::new_inline(
|
|
"Version".to_string(),
|
|
appearance.ui_font_family(),
|
|
REGULAR_TEXT_FONT_SIZE,
|
|
)
|
|
.with_color(faded_text_color)
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
if let Some(call_to_action_content) = call_to_action_content {
|
|
first_row.add_child(
|
|
appearance
|
|
.ui_builder()
|
|
.link(
|
|
call_to_action_content.text.into(),
|
|
None,
|
|
Some(Box::new(move |ctx| {
|
|
ctx.dispatch_typed_action(call_to_action_content.action.clone());
|
|
})),
|
|
self.version_info_cta_link_mouse_state.clone(),
|
|
)
|
|
.soft_wrap(false)
|
|
.build()
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
let mut second_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.0,
|
|
Align::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
appearance
|
|
.ui_builder()
|
|
.copy_button(16., self.copy_version_button_mouse_state.clone())
|
|
.build()
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(WorkspaceAction::CopyVersion(
|
|
version,
|
|
));
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Container::new(
|
|
Text::new_inline(
|
|
version.to_string(),
|
|
appearance.ui_font_family(),
|
|
REGULAR_TEXT_FONT_SIZE,
|
|
)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_margin_left(8.)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
if let Some(status_content) = status_content {
|
|
second_row.add_child(
|
|
Text::new_inline(
|
|
status_content.text.to_string(),
|
|
appearance.ui_font_family(),
|
|
REGULAR_TEXT_FONT_SIZE,
|
|
)
|
|
.with_color(status_content.color)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
let mut version_info = Flex::column();
|
|
version_info.add_child(first_row.finish());
|
|
version_info.add_child(
|
|
Container::new(second_row.finish())
|
|
.with_margin_top(5.)
|
|
.finish(),
|
|
);
|
|
version_info.finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsWidget for VersionInfoWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"version update"
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
if let Some(version) = ChannelState::app_version() {
|
|
Container::new(self.render_version_info(version, appearance, app))
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
} else {
|
|
log::error!("Shouldn't render VersionInfoWidget without GIT_RELEASE_TAG");
|
|
Empty::new().finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct LogoutWidget {
|
|
mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl LogoutWidget {
|
|
fn render_logout_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
appearance
|
|
.ui_builder()
|
|
.button(ButtonVariant::Secondary, self.mouse_state.clone())
|
|
.with_text_label(LOG_OUT_TEXT.into())
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(14.),
|
|
padding: Some(Coords::uniform(8.).left(32.).right(32.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(WorkspaceAction::LogOut);
|
|
})
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Widget displaying IAP credential state and a refresh button. Only
|
|
/// visible on staging channels where IAP is active.
|
|
#[cfg(not(target_family = "wasm"))]
|
|
#[derive(Default)]
|
|
struct IapCredentialsWidget {
|
|
refresh_button_mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
impl SettingsWidget for IapCredentialsWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"iap staging gcloud proxy credentials"
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
// `is_enabled()` gates widget registration in `MainSettingsPageView::new`,
|
|
// so `state()` should be `Some` here; bail out defensively though.
|
|
let Some(state) = IapManager::as_ref(app).state() else {
|
|
return Empty::new().finish();
|
|
};
|
|
let ansi_red: ColorU = appearance.theme().terminal_colors().bright.red.into();
|
|
let disabled: ColorU = appearance.theme().disabled_ui_text_color().into();
|
|
let active: ColorU = appearance.theme().active_ui_text_color().into();
|
|
let (status_text, status_color): (String, ColorU) = match &state {
|
|
IapCredentialsState::Missing => ("Not yet loaded".to_string(), disabled),
|
|
IapCredentialsState::Refreshing { .. } => ("Refreshing…".to_string(), active),
|
|
IapCredentialsState::Loaded(cached) => {
|
|
let remaining = cached
|
|
.expires_at
|
|
.saturating_duration_since(instant::Instant::now());
|
|
let mins = remaining.as_secs() / 60;
|
|
(format!("Loaded (refreshes in ~{mins}m)"), active)
|
|
}
|
|
IapCredentialsState::Failed { message, .. } => (format!("Failed: {message}"), ansi_red),
|
|
IapCredentialsState::EnvInjected { .. } => {
|
|
("Using injected token (WARP_IAP_TOKEN)".to_string(), active)
|
|
}
|
|
};
|
|
|
|
let is_refreshing = matches!(state, IapCredentialsState::Refreshing { .. });
|
|
|
|
let label = Align::new(
|
|
Text::new_inline(
|
|
"Staging IAP credentials".to_string(),
|
|
appearance.ui_font_family(),
|
|
REGULAR_TEXT_FONT_SIZE,
|
|
)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish();
|
|
|
|
let status = Container::new(
|
|
appearance
|
|
.ui_builder()
|
|
.paragraph(status_text)
|
|
.with_style(UiComponentStyles {
|
|
font_color: Some(status_color),
|
|
font_size: Some(REGULAR_TEXT_FONT_SIZE),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_margin_top(4.)
|
|
.finish();
|
|
|
|
let refresh_button = appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Secondary,
|
|
self.refresh_button_mouse_state.clone(),
|
|
)
|
|
.with_text_label(if is_refreshing {
|
|
"Refreshing…".into()
|
|
} else {
|
|
"Refresh".into()
|
|
})
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(12.),
|
|
padding: Some(Coords::uniform(6.).left(16.).right(16.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(MainPageAction::RefreshIapCredentials);
|
|
})
|
|
.finish();
|
|
|
|
let button_row = Container::new(Align::new(refresh_button).left().finish())
|
|
.with_margin_top(8.)
|
|
.finish();
|
|
|
|
Container::new(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(label)
|
|
.with_child(status)
|
|
.with_child(button_row)
|
|
.finish(),
|
|
)
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsWidget for LogoutWidget {
|
|
type View = MainSettingsPageView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"sign out log out logout"
|
|
}
|
|
|
|
fn should_render(&self, app: &AppContext) -> bool {
|
|
!AuthStateProvider::as_ref(app)
|
|
.get()
|
|
.is_anonymous_or_logged_out()
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
_view: &Self::View,
|
|
appearance: &Appearance,
|
|
_app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
Container::new(
|
|
Align::new(self.render_logout_button(appearance))
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.with_margin_top(VERTICAL_MARGIN)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsPageMeta for MainSettingsPageView {
|
|
fn section() -> SettingsSection {
|
|
SettingsSection::Account
|
|
}
|
|
|
|
fn should_render(&self, _ctx: &AppContext) -> bool {
|
|
true
|
|
}
|
|
|
|
fn on_page_selected(&mut self, _: bool, ctx: &mut ViewContext<Self>) {
|
|
// We want to immediately see if the user is part of a workspace rather than wait for the next poll.
|
|
std::mem::drop(
|
|
TeamUpdateManager::handle(ctx)
|
|
.update(ctx, |manager, ctx| manager.refresh_workspace_metadata(ctx)),
|
|
);
|
|
}
|
|
|
|
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
|
|
self.page.update_filter(query, ctx)
|
|
}
|
|
|
|
fn scroll_to_widget(&mut self, widget_id: &'static str) {
|
|
self.page.scroll_to_widget(widget_id)
|
|
}
|
|
|
|
fn clear_highlighted_widget(&mut self) {
|
|
self.page.clear_highlighted_widget();
|
|
}
|
|
}
|
|
|
|
impl From<ViewHandle<MainSettingsPageView>> for SettingsPageViewHandle {
|
|
fn from(view_handle: ViewHandle<MainSettingsPageView>) -> Self {
|
|
SettingsPageViewHandle::Main(view_handle)
|
|
}
|
|
}
|