- Gate server requests on available credentials - Run local child agents directly without a parent run ID - Include command IDs in Bedrock context and recognize transfer tools
511 lines
19 KiB
Rust
511 lines
19 KiB
Rust
use std::sync::Arc;
|
|
|
|
use enclose::enclose;
|
|
use galaxy_core::ui::appearance::Appearance;
|
|
use galaxy_graphql::billing::AddonCreditsOption;
|
|
use galaxyui::elements::{
|
|
Border, ChildView, Container, CrossAxisAlignment, Empty, Flex, HighlightedHyperlink,
|
|
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Text,
|
|
};
|
|
use galaxyui::fonts::Weight;
|
|
use galaxyui::ui_components::button::ButtonVariant;
|
|
use galaxyui::ui_components::components::{Coords, UiComponent as _, UiComponentStyles};
|
|
use galaxyui::{AppContext, Element, Entity, SingletonEntity as _, View, ViewContext, ViewHandle};
|
|
use itertools::Itertools as _;
|
|
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
|
|
|
use crate::features::FeatureFlag;
|
|
use crate::menu::MenuItemFields;
|
|
use crate::modal::{Modal, ModalEvent, MODAL_PADDING, MODAL_WIDTH};
|
|
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
|
use crate::send_telemetry_from_ctx;
|
|
use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent};
|
|
use crate::ui_components::blended_colors;
|
|
use crate::view_components::{Dropdown, DropdownAction, ToastFlavor};
|
|
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
|
|
|
const DENOMINATION_DROPDOWN_WIDTH: f32 = MODAL_WIDTH - 2. * MODAL_PADDING;
|
|
|
|
#[derive(Default)]
|
|
struct MouseStates {
|
|
enable_button: MouseStateHandle,
|
|
cancel_button: MouseStateHandle,
|
|
}
|
|
|
|
/// The body content of the enable auto-reload modal
|
|
pub struct EnableAutoReloadModalBody {
|
|
mouse_states: MouseStates,
|
|
denomination_dropdown: ViewHandle<Dropdown<Action>>,
|
|
addon_credits_options: Vec<AddonCreditsOption>,
|
|
selected_denomination_index: usize,
|
|
update_workspace_settings_loading: bool,
|
|
}
|
|
|
|
/// The main modal that wraps the body
|
|
pub struct EnableAutoReloadModal {
|
|
modal: ViewHandle<Modal<EnableAutoReloadModalBody>>,
|
|
}
|
|
|
|
/// Called when user clicks the 'x' OR cancel button
|
|
fn send_auto_reload_dismissed_telemetry<V: View>(ctx: &mut ViewContext<V>) {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AutoReloadModalClosed {
|
|
action: AutoReloadModalAction::Dismissed,
|
|
selected_credits: None,
|
|
banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(),
|
|
post_purchase_modal_flag_enabled: FeatureFlag::BuildPlanAutoReloadPostPurchaseModal
|
|
.is_enabled(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
impl EnableAutoReloadModalBody {
|
|
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
|
ctx.subscribe_to_model(
|
|
&PricingInfoModel::handle(ctx),
|
|
|me, _, event, ctx| match event {
|
|
PricingInfoModelEvent::PricingInfoUpdated => {
|
|
me.update_addon_credits_options(ctx);
|
|
ctx.notify();
|
|
}
|
|
},
|
|
);
|
|
|
|
ctx.subscribe_to_model(
|
|
&UserWorkspaces::handle(ctx),
|
|
|me, _handle, event, ctx| {
|
|
match event {
|
|
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
|
|
if me.update_workspace_settings_loading {
|
|
me.update_workspace_settings_loading = false;
|
|
|
|
// Emit telemetry for successful auto-reload enablement
|
|
let selected_credits = me
|
|
.addon_credits_options
|
|
.get(me.selected_denomination_index)
|
|
.map(|option| option.credits);
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AutoReloadModalClosed {
|
|
action: AutoReloadModalAction::EnabledAutoReload,
|
|
selected_credits,
|
|
banner_toggle_flag_enabled:
|
|
FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(),
|
|
post_purchase_modal_flag_enabled:
|
|
FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
|
message: "Auto-reload settings updated".to_string(),
|
|
flavor: ToastFlavor::Success,
|
|
});
|
|
ctx.emit(EnableAutoReloadModalBodyEvent::Close);
|
|
}
|
|
}
|
|
UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err)
|
|
if me.update_workspace_settings_loading =>
|
|
{
|
|
me.update_workspace_settings_loading = false;
|
|
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
|
message: "Failed to enable auto-reload. Please try updating your settings in Billing & usage.".to_string(),
|
|
flavor: ToastFlavor::Error,
|
|
});
|
|
ctx.notify();
|
|
}
|
|
_ => {}
|
|
}
|
|
},
|
|
);
|
|
|
|
let denomination_dropdown = ctx.add_typed_action_view(|ctx| {
|
|
let mut dropdown = Dropdown::new(ctx);
|
|
dropdown.set_top_bar_max_width(DENOMINATION_DROPDOWN_WIDTH);
|
|
dropdown.set_menu_width(DENOMINATION_DROPDOWN_WIDTH, ctx);
|
|
dropdown
|
|
});
|
|
|
|
let mut me = Self {
|
|
mouse_states: Default::default(),
|
|
denomination_dropdown,
|
|
addon_credits_options: Default::default(),
|
|
selected_denomination_index: 0,
|
|
update_workspace_settings_loading: false,
|
|
};
|
|
me.update_addon_credits_options(ctx);
|
|
me
|
|
}
|
|
|
|
fn update_addon_credits_options(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.addon_credits_options = PricingInfoModel::as_ref(ctx)
|
|
.addon_credits_options()
|
|
.map(|opts| opts.to_vec())
|
|
.unwrap_or_default();
|
|
|
|
let base_rate = self
|
|
.addon_credits_options
|
|
.first()
|
|
.map_or(0., |option| option.rate());
|
|
let items = self
|
|
.addon_credits_options
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, option)| {
|
|
let primary_text = format!(
|
|
"${:.0} / {} credits",
|
|
option.price_usd_cents as f32 / 100.,
|
|
option.credits
|
|
);
|
|
let discount_percent = if base_rate > 0.0 {
|
|
let actual_rate = option.rate();
|
|
((base_rate - actual_rate) / base_rate * 100.0).round() as u32
|
|
} else {
|
|
0
|
|
};
|
|
if discount_percent > 0 {
|
|
MenuItemFields::new_with_custom_label(
|
|
Arc::new(
|
|
enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
|
|
let text_color = appearance.theme().main_text_color(
|
|
if is_selected || is_hovered {
|
|
appearance.theme().accent()
|
|
} else {
|
|
appearance.theme().surface_1()
|
|
}
|
|
);
|
|
let main_text = Text::new_inline(
|
|
primary_text.clone(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(text_color.into())
|
|
.finish();
|
|
|
|
let discount_badge = Empty::new().finish();
|
|
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_child(main_text)
|
|
.with_child(discount_badge)
|
|
.finish()
|
|
}),
|
|
),
|
|
Some(primary_text),
|
|
)
|
|
.with_on_select_action(DropdownAction::select_action_and_close(
|
|
Action::SelectDenomination(index),
|
|
))
|
|
.into_item()
|
|
} else {
|
|
MenuItemFields::new(primary_text.clone())
|
|
.with_on_select_action(DropdownAction::select_action_and_close(
|
|
Action::SelectDenomination(index),
|
|
))
|
|
.into_item()
|
|
}
|
|
})
|
|
.collect_vec();
|
|
self.denomination_dropdown.update(ctx, |dropdown, ctx| {
|
|
dropdown.set_rich_items(items, ctx);
|
|
dropdown.set_selected_by_index(self.selected_denomination_index, ctx);
|
|
});
|
|
}
|
|
|
|
fn render_content(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let explanation_fragments = vec![
|
|
FormattedTextFragment::plain_text("When enabled, "),
|
|
FormattedTextFragment::bold("auto-reload"),
|
|
FormattedTextFragment::plain_text(
|
|
" will automatically purchase your selected package when you run out. ",
|
|
),
|
|
FormattedTextFragment::hyperlink(
|
|
"Learn more",
|
|
"https://docs.warp.dev/support-and-community/plans-and-billing/add-on-credits#id-2.-enable-auto-reload",
|
|
),
|
|
];
|
|
let explanation_text = galaxyui::elements::FormattedTextElement::new(
|
|
FormattedText::new([FormattedTextLine::Line(explanation_fragments)]),
|
|
appearance.ui_font_size(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_family(),
|
|
blended_colors::text_sub(theme, theme.surface_1()),
|
|
HighlightedHyperlink::default(),
|
|
)
|
|
.with_hyperlink_font_color(theme.accent().into_solid())
|
|
.register_default_click_handlers_with_action_support(|hyperlink_lens, _event, ctx| {
|
|
match hyperlink_lens {
|
|
galaxyui::elements::HyperlinkLens::Url(url) => {
|
|
ctx.open_url(url);
|
|
}
|
|
galaxyui::elements::HyperlinkLens::Action(_action_ref) => {}
|
|
}
|
|
})
|
|
.finish();
|
|
|
|
let denomination_dropdown =
|
|
Container::new(ChildView::new(&self.denomination_dropdown).finish())
|
|
.with_vertical_padding(16.)
|
|
.finish();
|
|
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_child(explanation_text)
|
|
.with_child(denomination_dropdown)
|
|
.finish()
|
|
}
|
|
|
|
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let cancel_button = appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Secondary,
|
|
self.mouse_states.cancel_button.clone(),
|
|
)
|
|
.with_style(UiComponentStyles {
|
|
font_weight: Some(Weight::Semibold),
|
|
padding: Some(Coords {
|
|
top: 6.,
|
|
bottom: 6.,
|
|
left: 12.,
|
|
right: 12.,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.with_text_label("Cancel".to_string())
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(Action::Cancel);
|
|
})
|
|
.finish();
|
|
|
|
let button_text = if self.update_workspace_settings_loading {
|
|
"Saving...".to_string()
|
|
} else {
|
|
"Enable".to_string()
|
|
};
|
|
|
|
let mut enable_button = appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Accent,
|
|
self.mouse_states.enable_button.clone(),
|
|
)
|
|
.with_style(UiComponentStyles {
|
|
font_weight: Some(Weight::Semibold),
|
|
padding: Some(Coords {
|
|
top: 6.,
|
|
bottom: 6.,
|
|
left: 12.,
|
|
right: 12.,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.with_text_label(button_text)
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(Action::Enable);
|
|
});
|
|
|
|
if self.update_workspace_settings_loading {
|
|
enable_button = enable_button.disable();
|
|
}
|
|
|
|
Flex::row()
|
|
.with_main_axis_alignment(MainAxisAlignment::End)
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_spacing(8.)
|
|
.with_child(cancel_button)
|
|
.with_child(enable_button.finish())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum EnableAutoReloadModalBodyEvent {
|
|
Close,
|
|
ShowToast {
|
|
message: String,
|
|
flavor: ToastFlavor,
|
|
},
|
|
}
|
|
|
|
impl Entity for EnableAutoReloadModalBody {
|
|
type Event = EnableAutoReloadModalBodyEvent;
|
|
}
|
|
|
|
impl View for EnableAutoReloadModalBody {
|
|
fn ui_name() -> &'static str {
|
|
"EnableAutoReloadModalBody"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
|
|
let content = Container::new(self.render_content(appearance))
|
|
.with_horizontal_padding(MODAL_PADDING)
|
|
.with_margin_top(0.) // let the header padding handle the top margin
|
|
.finish();
|
|
|
|
let separator = Container::new(Empty::new().finish())
|
|
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
|
|
.finish();
|
|
|
|
let buttons = Container::new(self.render_buttons(appearance))
|
|
.with_horizontal_padding(MODAL_PADDING)
|
|
.with_vertical_padding(12.)
|
|
.finish();
|
|
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_child(content)
|
|
.with_child(separator)
|
|
.with_child(buttons)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Action {
|
|
SelectDenomination(usize),
|
|
Cancel,
|
|
Enable,
|
|
}
|
|
|
|
impl galaxyui::TypedActionView for EnableAutoReloadModalBody {
|
|
type Action = Action;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
Action::SelectDenomination(index) => {
|
|
self.selected_denomination_index = *index;
|
|
ctx.notify();
|
|
}
|
|
Action::Cancel => {
|
|
send_auto_reload_dismissed_telemetry(ctx);
|
|
ctx.emit(EnableAutoReloadModalBodyEvent::Close);
|
|
}
|
|
Action::Enable => {
|
|
let workspaces = UserWorkspaces::as_ref(ctx);
|
|
let Some(team_uid) = workspaces.current_team_uid() else {
|
|
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
|
message: "Oops, something went wrong; your team's data could not be found."
|
|
.to_string(),
|
|
flavor: ToastFlavor::Error,
|
|
});
|
|
return;
|
|
};
|
|
|
|
// Set loading state before making the API call
|
|
self.update_workspace_settings_loading = true;
|
|
ctx.notify();
|
|
|
|
UserWorkspaces::handle(ctx).update(ctx, move |user_workspaces, ctx| {
|
|
user_workspaces.update_addon_credits_settings(
|
|
team_uid,
|
|
Some(true),
|
|
// TODO: consider allowing user to set max monthly spend too in this modal
|
|
None,
|
|
Some(self.addon_credits_options[self.selected_denomination_index].credits),
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main modal wrapper
|
|
impl EnableAutoReloadModal {
|
|
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
|
let body = ctx.add_typed_action_view(EnableAutoReloadModalBody::new);
|
|
|
|
let modal = ctx.add_typed_action_view(|ctx| {
|
|
Modal::new(Some("Enable auto reload?".to_string()), body.clone(), ctx).with_body_style(
|
|
UiComponentStyles {
|
|
// Padding of 0 here since we add a horizontal bar that needs to span the full width in the body
|
|
// So we handle padding in the body itself
|
|
padding: Some(Coords::uniform(0.)),
|
|
..Default::default()
|
|
},
|
|
)
|
|
});
|
|
|
|
ctx.subscribe_to_view(&modal, |_, _, event, ctx| match event {
|
|
ModalEvent::Close => {
|
|
// "x" clicked
|
|
send_auto_reload_dismissed_telemetry(ctx);
|
|
ctx.emit(EnableAutoReloadModalEvent::Close);
|
|
}
|
|
});
|
|
|
|
ctx.subscribe_to_view(&body, |_, _, event, ctx| match event {
|
|
EnableAutoReloadModalBodyEvent::Close => {
|
|
ctx.emit(EnableAutoReloadModalEvent::Close);
|
|
}
|
|
EnableAutoReloadModalBodyEvent::ShowToast { message, flavor } => {
|
|
ctx.emit(EnableAutoReloadModalEvent::ShowToast {
|
|
message: message.clone(),
|
|
flavor: *flavor,
|
|
});
|
|
}
|
|
});
|
|
|
|
Self { modal }
|
|
}
|
|
|
|
pub fn set_selected_denomination_by_credits(
|
|
&mut self,
|
|
credits: i32,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.modal.update(ctx, |modal, ctx| {
|
|
modal.body().update(ctx, |body, ctx| {
|
|
if let Some((index, _)) = body
|
|
.addon_credits_options
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, option)| option.credits == credits)
|
|
{
|
|
body.selected_denomination_index = index;
|
|
// Update the dropdown to reflect the new selection
|
|
body.denomination_dropdown.update(ctx, |dropdown, ctx| {
|
|
dropdown.set_selected_by_index(index, ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum EnableAutoReloadModalEvent {
|
|
Close,
|
|
ShowToast {
|
|
message: String,
|
|
flavor: ToastFlavor,
|
|
},
|
|
}
|
|
|
|
impl Entity for EnableAutoReloadModal {
|
|
type Event = EnableAutoReloadModalEvent;
|
|
}
|
|
|
|
impl galaxyui::TypedActionView for EnableAutoReloadModal {
|
|
type Action = ();
|
|
}
|
|
|
|
impl View for EnableAutoReloadModal {
|
|
fn ui_name() -> &'static str {
|
|
"EnableAutoReloadModal"
|
|
}
|
|
|
|
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
|
ChildView::new(&self.modal).finish()
|
|
}
|
|
}
|