Merge branch 'master' of gitlab.com:samnasbo/shared/galaxy into experiment/cross-check
# Conflicts: # app/src/settings_view/mod.rs
This commit is contained in:
@@ -35,13 +35,7 @@ use regex::Regex;
|
||||
use settings::{Setting, ToggleableSetting};
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use super::custom_inference_modal::{
|
||||
CustomEndpointModal, CustomEndpointModalEvent, CustomEndpointModalViewState,
|
||||
};
|
||||
use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent};
|
||||
use super::remove_custom_endpoint_confirmation_dialog::{
|
||||
RemoveCustomEndpointConfirmationDialog, RemoveCustomEndpointConfirmationDialogEvent,
|
||||
};
|
||||
use super::set_default_model_modal::{SetDefaultModelModalBody, SetDefaultModelModalBodyEvent};
|
||||
use super::settings_page::{
|
||||
build_sub_header, build_toggle_element, render_body_item_label,
|
||||
@@ -712,13 +706,6 @@ pub struct AISettingsPageView {
|
||||
#[cfg(feature = "local_fs")]
|
||||
add_router_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Custom inference (custom endpoints)
|
||||
custom_endpoint_modal_state: CustomEndpointModalViewState,
|
||||
remove_custom_endpoint_confirmation_dialog: ViewHandle<RemoveCustomEndpointConfirmationDialog>,
|
||||
pending_remove_custom_endpoint_index: Option<usize>,
|
||||
custom_inference_add_button: ViewHandle<ActionButton>,
|
||||
custom_endpoint_edit_buttons: Vec<ViewHandle<ActionButton>>,
|
||||
|
||||
// Prompt offering to switch the default Agent Mode model after a BYO key or
|
||||
// custom endpoint is saved while the default isn't backed by a credential.
|
||||
set_default_model_modal: ModalViewState<Modal<SetDefaultModelModalBody>>,
|
||||
@@ -769,7 +756,6 @@ impl AISettingsPageView {
|
||||
ctx,
|
||||
);
|
||||
|
||||
me.sync_custom_endpoint_buttons(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
@@ -1079,7 +1065,6 @@ impl AISettingsPageView {
|
||||
// Re-render if teams-related data changed that may affect whether features such as voice input are enabled.
|
||||
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
|
||||
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
||||
me.sync_custom_endpoint_buttons(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
@@ -1150,7 +1135,6 @@ impl AISettingsPageView {
|
||||
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
|
||||
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
||||
me.sync_context_window_editor(ctx, false);
|
||||
me.sync_custom_endpoint_buttons(ctx);
|
||||
// Driving the prompt off the key-store update (rather than the editor's
|
||||
// blur/Enter) means it fires reliably however the key was committed —
|
||||
// clicking outside the field, pressing Enter, or tabbing away.
|
||||
@@ -1233,7 +1217,6 @@ impl AISettingsPageView {
|
||||
Self::refresh_mcp_allowlist_dropdown(&me.mcp_allowlist_dropdown, ctx);
|
||||
Self::refresh_mcp_denylist_dropdown(&me.mcp_denylist_dropdown, ctx);
|
||||
me.sync_context_window_editor(ctx, true);
|
||||
me.sync_custom_endpoint_buttons(ctx);
|
||||
}
|
||||
AISettingsChangedEvent::VoiceInputEnabled { .. } => {
|
||||
me.update_voice_input_dropdown_enablement(ctx);
|
||||
@@ -1723,68 +1706,6 @@ impl AISettingsPageView {
|
||||
button.set_disabled(!is_any_ai_enabled, ctx);
|
||||
});
|
||||
|
||||
// Custom inference
|
||||
let custom_inference_controls_enabled =
|
||||
is_any_ai_enabled && UserWorkspaces::as_ref(ctx).is_custom_inference_enabled(ctx);
|
||||
let custom_inference_add_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("+ Add custom model", SecondaryTheme)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::OpenAddCustomEndpointModal);
|
||||
})
|
||||
});
|
||||
custom_inference_add_button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(!custom_inference_controls_enabled, ctx);
|
||||
});
|
||||
|
||||
let custom_endpoint_modal_body =
|
||||
ctx.add_typed_action_view(|ctx| CustomEndpointModal::new(None, None, ctx));
|
||||
ctx.subscribe_to_view(&custom_endpoint_modal_body, |me, _, event, ctx| {
|
||||
me.handle_custom_endpoint_modal_event(event, ctx);
|
||||
});
|
||||
|
||||
let custom_endpoint_modal_view = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(
|
||||
Some("Add custom endpoint".to_string()),
|
||||
custom_endpoint_modal_body.clone(),
|
||||
ctx,
|
||||
)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
width: Some(560.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_header_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 24.,
|
||||
bottom: 0.,
|
||||
left: 24.,
|
||||
right: 24.,
|
||||
}),
|
||||
font_size: Some(16.),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.with_body_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 0.,
|
||||
bottom: 24.,
|
||||
left: 24.,
|
||||
right: 0.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_background_opacity(100)
|
||||
.with_max_height_percentage(CUSTOM_ENDPOINT_MODAL_MAX_HEIGHT_PERCENTAGE)
|
||||
.with_dismiss_on_click()
|
||||
.with_dismiss_keystroke(Keystroke::parse("escape").unwrap())
|
||||
});
|
||||
ctx.subscribe_to_view(&custom_endpoint_modal_view, |me, _, event, ctx| {
|
||||
me.handle_custom_endpoint_modal_close_event(event, ctx);
|
||||
});
|
||||
|
||||
let custom_endpoint_modal_state =
|
||||
CustomEndpointModalViewState::new(ModalViewState::new(custom_endpoint_modal_view));
|
||||
|
||||
let set_default_model_modal_body = ctx.add_typed_action_view(SetDefaultModelModalBody::new);
|
||||
ctx.subscribe_to_view(&set_default_model_modal_body, |me, _, event, ctx| {
|
||||
me.handle_set_default_model_modal_event(event, ctx);
|
||||
@@ -1817,21 +1738,6 @@ impl AISettingsPageView {
|
||||
let set_default_model_modal = ModalViewState::new(set_default_model_modal_view);
|
||||
let last_seen_provider_keys = ApiKeyManager::as_ref(ctx).keys().clone();
|
||||
|
||||
let remove_custom_endpoint_confirmation_dialog =
|
||||
ctx.add_typed_action_view(RemoveCustomEndpointConfirmationDialog::new);
|
||||
ctx.subscribe_to_view(
|
||||
&remove_custom_endpoint_confirmation_dialog,
|
||||
|me, _, event, ctx| {
|
||||
me.handle_remove_custom_endpoint_confirmation_dialog_event(event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let custom_endpoint_edit_buttons = Self::create_custom_endpoint_edit_buttons(
|
||||
ApiKeyManager::as_ref(ctx).keys().custom_endpoints.len(),
|
||||
custom_inference_controls_enabled,
|
||||
ctx,
|
||||
);
|
||||
|
||||
let agent_toolbar_inline_editor = ctx.add_typed_action_view(|ctx| {
|
||||
AgentToolbarInlineEditor::new(AgentToolbarEditorMode::AgentView, ctx)
|
||||
});
|
||||
@@ -1963,11 +1869,6 @@ impl AISettingsPageView {
|
||||
router_views,
|
||||
#[cfg(feature = "local_fs")]
|
||||
add_router_button,
|
||||
custom_endpoint_modal_state,
|
||||
remove_custom_endpoint_confirmation_dialog,
|
||||
pending_remove_custom_endpoint_index: None,
|
||||
custom_inference_add_button,
|
||||
custom_endpoint_edit_buttons,
|
||||
set_default_model_modal,
|
||||
last_seen_provider_keys,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -1990,17 +1891,9 @@ impl AISettingsPageView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if self.custom_endpoint_modal_state.is_open() {
|
||||
Some(self.custom_endpoint_modal_state.render())
|
||||
} else if self.set_default_model_modal.is_open() {
|
||||
pub fn get_modal_content(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if self.set_default_model_modal.is_open() {
|
||||
Some(self.set_default_model_modal.render())
|
||||
} else if self
|
||||
.remove_custom_endpoint_confirmation_dialog
|
||||
.as_ref(app)
|
||||
.is_visible()
|
||||
{
|
||||
Some(ChildView::new(&self.remove_custom_endpoint_confirmation_dialog).finish())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2178,336 +2071,6 @@ impl AISettingsPageView {
|
||||
self.show_set_default_model_modal(description, choices, ctx);
|
||||
}
|
||||
|
||||
/// After a custom endpoint is added or saved, offer to switch the default
|
||||
/// Agent Mode model to one of its models.
|
||||
fn maybe_prompt_set_default_model_for_custom_endpoint(
|
||||
&mut self,
|
||||
endpoint_index: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
return;
|
||||
}
|
||||
if !Self::should_offer_default_model_switch(ctx) {
|
||||
return;
|
||||
}
|
||||
let Some(endpoint) = ApiKeyManager::as_ref(ctx)
|
||||
.keys()
|
||||
.custom_endpoints
|
||||
.get(endpoint_index)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Build directly from the endpoint's models rather than the synthetic
|
||||
// `custom_llms`, which are rebuilt asynchronously on `KeysUpdated`.
|
||||
let choices: Vec<(LLMId, String)> = endpoint
|
||||
.models
|
||||
.iter()
|
||||
.filter(|m| !m.name.trim().is_empty() && !m.config_key.is_empty())
|
||||
.map(|m| {
|
||||
(
|
||||
LLMId::from(m.config_key.clone()),
|
||||
m.display_label().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_default = Self::active_base_model_display_name(ctx);
|
||||
let description = format!(
|
||||
"You added the \"{}\" custom endpoint, but your default model is currently set to \
|
||||
{current_default}, which won't work without Warp credits. Would you like to change \
|
||||
your default model?",
|
||||
endpoint.name
|
||||
);
|
||||
self.show_set_default_model_modal(description, choices, ctx);
|
||||
}
|
||||
|
||||
fn sync_custom_endpoint_buttons(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let enabled = Self::can_use_custom_inference_controls(ctx);
|
||||
|
||||
self.custom_inference_add_button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(!enabled, ctx);
|
||||
});
|
||||
|
||||
let endpoint_count = ApiKeyManager::as_ref(ctx).keys().custom_endpoints.len();
|
||||
if self.custom_endpoint_edit_buttons.len() != endpoint_count {
|
||||
self.custom_endpoint_edit_buttons =
|
||||
Self::create_custom_endpoint_edit_buttons(endpoint_count, enabled, ctx);
|
||||
} else {
|
||||
for button in &self.custom_endpoint_edit_buttons {
|
||||
button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(!enabled, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_custom_endpoint_edit_buttons(
|
||||
count: usize,
|
||||
enabled: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<ViewHandle<ActionButton>> {
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let button = ctx.add_typed_action_view(move |_| {
|
||||
ActionButton::new("Edit", SecondaryTheme)
|
||||
.with_icon(Icon::Pencil)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
AISettingsPageAction::OpenEditCustomEndpointModal(index),
|
||||
);
|
||||
})
|
||||
});
|
||||
button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(!enabled, ctx);
|
||||
});
|
||||
button
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn can_use_custom_inference_controls(app: &AppContext) -> bool {
|
||||
FeatureFlag::CustomInferenceEndpoints.is_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
&& UserWorkspaces::as_ref(app).is_custom_inference_enabled(app)
|
||||
}
|
||||
|
||||
fn show_add_custom_endpoint_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
return;
|
||||
}
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
self.pending_remove_custom_endpoint_index = None;
|
||||
|
||||
self.custom_endpoint_modal_state
|
||||
.set_title(Some("Add custom endpoint".to_string()), ctx);
|
||||
self.custom_endpoint_modal_state.prefill(None, None, ctx);
|
||||
self.custom_endpoint_modal_state.open(ctx);
|
||||
ctx.emit(AISettingsPageEvent::ShowModal);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn show_edit_custom_endpoint_modal(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
return;
|
||||
}
|
||||
let endpoint = ApiKeyManager::as_ref(ctx)
|
||||
.keys()
|
||||
.custom_endpoints
|
||||
.get(index)
|
||||
.cloned();
|
||||
if endpoint.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
self.pending_remove_custom_endpoint_index = None;
|
||||
|
||||
self.custom_endpoint_modal_state
|
||||
.set_title(Some("Edit custom endpoint".to_string()), ctx);
|
||||
self.custom_endpoint_modal_state
|
||||
.prefill(endpoint.as_ref(), Some(index), ctx);
|
||||
self.custom_endpoint_modal_state.open(ctx);
|
||||
ctx.emit(AISettingsPageEvent::ShowModal);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn hide_custom_endpoint_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.custom_endpoint_modal_state.close(ctx);
|
||||
ctx.emit(AISettingsPageEvent::HideModal);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_custom_endpoint_modal_close_event(
|
||||
&mut self,
|
||||
event: &ModalEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ModalEvent::Close => {
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_custom_endpoint_modal_event(
|
||||
&mut self,
|
||||
event: &CustomEndpointModalEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
CustomEndpointModalEvent::Close => {
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
}
|
||||
CustomEndpointModalEvent::AddEndpoint {
|
||||
name,
|
||||
url,
|
||||
api_key,
|
||||
models,
|
||||
} => {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
return;
|
||||
}
|
||||
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.add_custom_endpoint(
|
||||
name.clone(),
|
||||
url.clone(),
|
||||
api_key.clone(),
|
||||
models.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
|
||||
let window_id = ctx.window_id();
|
||||
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = crate::view_components::DismissibleToast::success(
|
||||
"Endpoint added".to_string(),
|
||||
);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
|
||||
// The new endpoint is appended last.
|
||||
let new_index = ApiKeyManager::as_ref(ctx)
|
||||
.keys()
|
||||
.custom_endpoints
|
||||
.len()
|
||||
.saturating_sub(1);
|
||||
self.maybe_prompt_set_default_model_for_custom_endpoint(new_index, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
CustomEndpointModalEvent::SaveEndpoint {
|
||||
index,
|
||||
name,
|
||||
url,
|
||||
api_key,
|
||||
models,
|
||||
} => {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
return;
|
||||
}
|
||||
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.save_custom_endpoint(
|
||||
*index,
|
||||
name.clone(),
|
||||
url.clone(),
|
||||
api_key.clone(),
|
||||
models.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
|
||||
let window_id = ctx.window_id();
|
||||
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = crate::view_components::DismissibleToast::success(
|
||||
"Endpoint saved".to_string(),
|
||||
);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
self.maybe_prompt_set_default_model_for_custom_endpoint(*index, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
CustomEndpointModalEvent::RemoveEndpoint { index } => {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
return;
|
||||
}
|
||||
self.hide_custom_endpoint_modal(ctx);
|
||||
self.show_remove_custom_endpoint_confirmation_dialog(*index, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_remove_custom_endpoint_confirmation_dialog(
|
||||
&mut self,
|
||||
index: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
return;
|
||||
}
|
||||
let endpoint = ApiKeyManager::as_ref(ctx)
|
||||
.keys()
|
||||
.custom_endpoints
|
||||
.get(index)
|
||||
.cloned();
|
||||
let Some(endpoint) = endpoint else {
|
||||
return;
|
||||
};
|
||||
|
||||
let model_labels = endpoint
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.alias.clone().unwrap_or_else(|| model.name.clone()))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect();
|
||||
|
||||
self.pending_remove_custom_endpoint_index = Some(index);
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.show(index, endpoint.name.clone(), model_labels, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_remove_custom_endpoint_confirmation_dialog_event(
|
||||
&mut self,
|
||||
event: &RemoveCustomEndpointConfirmationDialogEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
RemoveCustomEndpointConfirmationDialogEvent::Cancel => {
|
||||
self.pending_remove_custom_endpoint_index = None;
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
RemoveCustomEndpointConfirmationDialogEvent::Confirm(index) => {
|
||||
if !Self::can_use_custom_inference_controls(ctx) {
|
||||
self.pending_remove_custom_endpoint_index = None;
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.remove_custom_endpoint(*index, ctx);
|
||||
});
|
||||
self.pending_remove_custom_endpoint_index = None;
|
||||
self.remove_custom_endpoint_confirmation_dialog
|
||||
.update(ctx, |dialog, ctx| {
|
||||
dialog.hide(ctx);
|
||||
});
|
||||
self.sync_custom_endpoint_buttons(ctx);
|
||||
|
||||
let window_id = ctx.window_id();
|
||||
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = crate::view_components::DismissibleToast::success(
|
||||
"Endpoint removed".to_string(),
|
||||
);
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn create_grok_code_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
@@ -3642,9 +3205,6 @@ pub enum AISettingsPageAction {
|
||||
#[cfg(feature = "local_fs")]
|
||||
OpenAddCustomRouter,
|
||||
|
||||
// Custom inference
|
||||
OpenAddCustomEndpointModal,
|
||||
OpenEditCustomEndpointModal(usize),
|
||||
ConnectGrokSubscription,
|
||||
DisconnectGrokSubscription,
|
||||
|
||||
@@ -4454,12 +4014,6 @@ impl TypedActionView for AISettingsPageView {
|
||||
AISettingsPageAction::OpenAddCustomRouter => {
|
||||
ctx.emit(AISettingsPageEvent::OpenCustomRouterEditor(None));
|
||||
}
|
||||
AISettingsPageAction::OpenAddCustomEndpointModal => {
|
||||
self.show_add_custom_endpoint_modal(ctx);
|
||||
}
|
||||
AISettingsPageAction::OpenEditCustomEndpointModal(index) => {
|
||||
self.show_edit_custom_endpoint_modal(*index, ctx);
|
||||
}
|
||||
AISettingsPageAction::ToggleCloudHandoff => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
@@ -8542,11 +8096,7 @@ impl ApiKeysWidget {
|
||||
.with_child(chips)
|
||||
.finish();
|
||||
|
||||
let edit_button = view
|
||||
.custom_endpoint_edit_buttons
|
||||
.get(index)
|
||||
.map(|button| button.as_ref(app).render(app))
|
||||
.unwrap_or_else(|| Empty::new().finish());
|
||||
let edit_button = Empty::new().finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
@@ -8796,7 +8346,6 @@ impl SettingsWidget for ApiKeysWidget {
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(header_left)
|
||||
.with_child(view.custom_inference_add_button.as_ref(app).render(app))
|
||||
.finish();
|
||||
|
||||
column.add_child(
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use thousands::Separable;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Empty,
|
||||
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
|
||||
Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::Element;
|
||||
|
||||
use crate::settings_view::billing_and_usage_page_v2::{
|
||||
AGGREGATE_CREDITS_DOT_COLOR, AMBIENT_CREDITS_DOT_COLOR, BASE_CREDITS_DOT_COLOR,
|
||||
BONUS_CREDITS_DOT_COLOR, PAYG_CREDITS_DOT_COLOR,
|
||||
};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
|
||||
BillingCycleUsageEntry,
|
||||
};
|
||||
|
||||
// for a bunch of this (min fill ratio, cost type order, ... )
|
||||
// you will find analogous ts code in warp-server
|
||||
pub const ROW_BORDER_RADIUS: f32 = 8.;
|
||||
pub const ROW_BORDER_WIDTH: f32 = 1.;
|
||||
pub const TOOLTIP_GAP: f32 = 6.;
|
||||
|
||||
const COST_TYPE_ORDER: &[AiCreditsUsageAndCostType] = &[
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageAndCostType::BonusGrant,
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
AiCreditsUsageAndCostType::AmbientBonusGrant,
|
||||
];
|
||||
const BUCKET_ORDER: &[AiCreditsUsageBucket] = &[
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageBucket::Compute,
|
||||
AiCreditsUsageBucket::Platform,
|
||||
];
|
||||
|
||||
/// One colored slice of the stacked bar. `cost_type` drives color; `usage_bucket`
|
||||
/// drives the tooltip breakdown.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BarSegment {
|
||||
pub cost_type: AiCreditsUsageAndCostType,
|
||||
pub usage_bucket: AiCreditsUsageBucket,
|
||||
pub credits: i64,
|
||||
pub cost_cents: i64,
|
||||
}
|
||||
|
||||
/// Shared mouse-state bag for the billing-and-usage section: the
|
||||
/// All/Local/Cloud filter pills plus a tooltip handle for every interactive
|
||||
/// element keyed by string id (per-member rows, team-totals cards, ...).
|
||||
pub struct BillingUsageMouseStates {
|
||||
pub filter_all: MouseStateHandle,
|
||||
pub filter_local: MouseStateHandle,
|
||||
pub filter_cloud: MouseStateHandle,
|
||||
tooltip_by_subject: RefCell<HashMap<String, MouseStateHandle>>,
|
||||
}
|
||||
|
||||
impl Default for BillingUsageMouseStates {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter_all: MouseStateHandle::default(),
|
||||
filter_local: MouseStateHandle::default(),
|
||||
filter_cloud: MouseStateHandle::default(),
|
||||
tooltip_by_subject: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingUsageMouseStates {
|
||||
pub fn tooltip_mouse_state(&self, key: &str) -> MouseStateHandle {
|
||||
let mut map = self.tooltip_by_subject.borrow_mut();
|
||||
map.entry(key.to_string()).or_default().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Swatch color for one cost-type bucket, mirroring the legend palette.
|
||||
pub fn cost_type_color(cost_type: &AiCreditsUsageAndCostType) -> ColorU {
|
||||
match cost_type {
|
||||
AiCreditsUsageAndCostType::BaseLimit => BASE_CREDITS_DOT_COLOR,
|
||||
AiCreditsUsageAndCostType::BonusGrant => BONUS_CREDITS_DOT_COLOR,
|
||||
AiCreditsUsageAndCostType::Payg => PAYG_CREDITS_DOT_COLOR,
|
||||
AiCreditsUsageAndCostType::AmbientBonusGrant => AMBIENT_CREDITS_DOT_COLOR,
|
||||
AiCreditsUsageAndCostType::Aggregate => AGGREGATE_CREDITS_DOT_COLOR,
|
||||
AiCreditsUsageAndCostType::Other(_) => BASE_CREDITS_DOT_COLOR,
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_type_label(cost_type: &AiCreditsUsageAndCostType) -> &'static str {
|
||||
match cost_type {
|
||||
AiCreditsUsageAndCostType::BaseLimit => "Base",
|
||||
AiCreditsUsageAndCostType::BonusGrant => "Add-ons",
|
||||
AiCreditsUsageAndCostType::Payg => "Pay-as-you-go",
|
||||
AiCreditsUsageAndCostType::AmbientBonusGrant => "Cloud-only",
|
||||
AiCreditsUsageAndCostType::Aggregate => "Combined",
|
||||
AiCreditsUsageAndCostType::Other(_) => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_label(bucket: &AiCreditsUsageBucket) -> &'static str {
|
||||
match bucket {
|
||||
AiCreditsUsageBucket::Ai => "AI",
|
||||
AiCreditsUsageBucket::Compute => "Compute",
|
||||
AiCreditsUsageBucket::Platform => "Platform",
|
||||
AiCreditsUsageBucket::SuggestedCodeDiffs => "Suggested code diffs",
|
||||
AiCreditsUsageBucket::Voice => "Voice",
|
||||
AiCreditsUsageBucket::Aggregate => "Total",
|
||||
AiCreditsUsageBucket::Other(_) => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_type_rank(cost_type: &AiCreditsUsageAndCostType) -> usize {
|
||||
COST_TYPE_ORDER
|
||||
.iter()
|
||||
.position(|c| c == cost_type)
|
||||
.unwrap_or(COST_TYPE_ORDER.len())
|
||||
}
|
||||
|
||||
fn bucket_rank(bucket: &AiCreditsUsageBucket) -> usize {
|
||||
BUCKET_ORDER
|
||||
.iter()
|
||||
.position(|b| b == bucket)
|
||||
.unwrap_or(BUCKET_ORDER.len())
|
||||
}
|
||||
|
||||
fn segment_sort_key(segment: &BarSegment) -> (usize, usize) {
|
||||
(
|
||||
cost_type_rank(&segment.cost_type),
|
||||
bucket_rank(&segment.usage_bucket),
|
||||
)
|
||||
}
|
||||
|
||||
/// Group `entries` by `(cost_type, usage_bucket)` into [`BarSegment`]s; returns
|
||||
/// sorted segments plus row totals. Linear Vec lookup since cynic enums don't
|
||||
/// impl Hash and per-row entry counts are small.
|
||||
pub fn aggregate_segments<'a>(
|
||||
entries: impl IntoIterator<Item = &'a BillingCycleUsageEntry>,
|
||||
) -> (Vec<BarSegment>, i64, i64) {
|
||||
let mut segments: Vec<BarSegment> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
if let Some(existing) = segments
|
||||
.iter_mut()
|
||||
.find(|s| s.cost_type == entry.cost_type && s.usage_bucket == entry.usage_bucket)
|
||||
{
|
||||
existing.credits += entry.credits_used as i64;
|
||||
existing.cost_cents += entry.cost_cents as i64;
|
||||
} else {
|
||||
segments.push(BarSegment {
|
||||
cost_type: entry.cost_type.clone(),
|
||||
usage_bucket: entry.usage_bucket.clone(),
|
||||
credits: entry.credits_used as i64,
|
||||
cost_cents: entry.cost_cents as i64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
segments.retain(|s| s.credits > 0);
|
||||
segments.sort_by_key(segment_sort_key);
|
||||
|
||||
let total_credits = segments.iter().map(|s| s.credits).sum();
|
||||
let total_cost_cents = segments.iter().map(|s| s.cost_cents).sum();
|
||||
|
||||
(segments, total_credits, total_cost_cents)
|
||||
}
|
||||
|
||||
/// Drops Voice / SuggestedCodeDiffs entries from the usage view.
|
||||
///
|
||||
/// These buckets are tracked server-side against their own dedicated per-cycle
|
||||
/// limits (`VoiceRequestLimit` / `SuggestedCodeDiffsLimit`) rather than the
|
||||
/// AI/Compute base credit pool — see
|
||||
/// `model/sql/ai_credits_usage_and_cost/get_base_limits_usage.sql` and
|
||||
/// `isBaseLimitExhaustedForBucket` in warp-server. Records are written with
|
||||
/// `cost_type = BASE_LIMIT` and `cost_cents = 0`, so surfacing them here
|
||||
/// would inflate the per-row `total_credits` and skew the `used / limit`
|
||||
/// math without contributing to anything the user is actually billed for.
|
||||
///
|
||||
/// TODO: this also hides the rare case where a user blows past their
|
||||
/// dedicated Voice or SuggestedCodeDiffs limit and the resolver falls
|
||||
/// through to bonus grants — those entries would have real `cost_cents`
|
||||
/// and *do* draw down add-on credits. In practice ~nobody (maybe ZL?) hits
|
||||
/// those limits, so we filter unconditionally for now; revisit if usage of
|
||||
/// those features ever grows enough that the overflow matters.
|
||||
pub fn filter_legacy_buckets(entries: &[BillingCycleUsageEntry]) -> Vec<BillingCycleUsageEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.usage_bucket != AiCreditsUsageBucket::Voice
|
||||
&& e.usage_bucket != AiCreditsUsageBucket::SuggestedCodeDiffs
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cost-type buckets to surface in the usage legend, in display order.
|
||||
///
|
||||
/// Mirrors the buckets the stacked bars actually render: legacy buckets are
|
||||
/// dropped (see [`filter_legacy_buckets`]) and a cost type only counts when it
|
||||
/// has real usage (`credits_used > 0`), exactly like [`aggregate_segments`],
|
||||
/// which retains only segments with `credits > 0`. Without the usage check a
|
||||
/// zero-credit entry (e.g. an untouched base-limit row) would list "Base" in
|
||||
/// the legend even though it contributed nothing to the chart.
|
||||
pub fn legend_cost_types(entries: &[BillingCycleUsageEntry]) -> Vec<AiCreditsUsageAndCostType> {
|
||||
let filtered = filter_legacy_buckets(entries);
|
||||
[
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageAndCostType::BonusGrant,
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
AiCreditsUsageAndCostType::AmbientBonusGrant,
|
||||
AiCreditsUsageAndCostType::Aggregate,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|cost_type| {
|
||||
filtered
|
||||
.iter()
|
||||
.any(|e| e.cost_type == *cost_type && e.credits_used > 0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// "Is there any data in `entries` that's not my own?"
|
||||
pub fn has_non_viewer_data(entries: &[BillingCycleUsageEntry], viewer_uid: Option<&str>) -> bool {
|
||||
entries.iter().any(|e| match &e.subject_type {
|
||||
AiCreditsUsageAndCostSubjectType::Team => e.credits_used > 0,
|
||||
_ => match (e.subject_uid.as_deref(), viewer_uid) {
|
||||
(Some(uid), Some(viewer)) => uid != viewer,
|
||||
// Unknown subject — conservatively treat as non-viewer.
|
||||
_ => true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn format_credits(credits: i64) -> String {
|
||||
credits.separate_with_commas()
|
||||
}
|
||||
|
||||
pub fn format_cost_cents(cents: i64) -> String {
|
||||
let dollars = cents / 100;
|
||||
let remainder = (cents.abs() % 100) as u8;
|
||||
if dollars < 0 {
|
||||
format!(
|
||||
"-${}.{remainder:02}",
|
||||
dollars.unsigned_abs().separate_with_commas()
|
||||
)
|
||||
} else {
|
||||
format!("${}.{remainder:02}", dollars.separate_with_commas())
|
||||
}
|
||||
}
|
||||
|
||||
/// Section subheader (e.g. "Team totals", "Member usage"). One step below
|
||||
/// the v2 page's bold section title.
|
||||
pub fn render_section_subheader(label: &str, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new_inline(label.to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Medium))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Per-cost-type breakdown card. Parameterized by raw segments and totals so
|
||||
/// it can back team-totals card hovers as well as per-member row hovers.
|
||||
pub fn render_breakdown_tooltip(
|
||||
segments: &[BarSegment],
|
||||
total_credits: i64,
|
||||
total_cost_cents: i64,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let bg = theme.background().into_solid();
|
||||
let main = blended_colors::text_main(theme, bg);
|
||||
let sub = blended_colors::text_sub(theme, bg);
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(6.);
|
||||
|
||||
for line in segments {
|
||||
let label = if matches!(line.usage_bucket, AiCreditsUsageBucket::Aggregate) {
|
||||
cost_type_label(&line.cost_type).to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} ({})",
|
||||
cost_type_label(&line.cost_type),
|
||||
bucket_label(&line.usage_bucket)
|
||||
)
|
||||
};
|
||||
|
||||
column.add_child(render_tooltip_row(
|
||||
Some(cost_type_color(&line.cost_type)),
|
||||
label,
|
||||
line.credits,
|
||||
line.cost_cents,
|
||||
sub,
|
||||
main,
|
||||
font_family,
|
||||
/* bold */ false,
|
||||
));
|
||||
}
|
||||
|
||||
// Divider before the total row.
|
||||
column.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_padding_top(1.)
|
||||
.with_background_color(theme.outline().into_solid())
|
||||
.finish(),
|
||||
);
|
||||
|
||||
column.add_child(render_tooltip_row(
|
||||
/* no swatch on the total row */ None,
|
||||
"Total usage".to_string(),
|
||||
total_credits,
|
||||
total_cost_cents,
|
||||
main,
|
||||
main,
|
||||
font_family,
|
||||
/* bold */ true,
|
||||
));
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(column.finish())
|
||||
.with_background_color(bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
|
||||
.with_uniform_padding(10.)
|
||||
.with_drop_shadow(
|
||||
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
|
||||
.with_offset(vec2f(0., 4.)),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_min_width(200.)
|
||||
.with_max_width(320.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Single tooltip row: `[swatch + label] [spacer] [credits / cost]` with
|
||||
/// fixed-width right-aligned number columns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tooltip_row(
|
||||
swatch_color: Option<ColorU>,
|
||||
label: String,
|
||||
credits: i64,
|
||||
cost_cents: i64,
|
||||
label_color: ColorU,
|
||||
value_color: ColorU,
|
||||
font_family: warpui::fonts::FamilyId,
|
||||
bold: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let style = if bold {
|
||||
Properties::default().weight(Weight::Semibold)
|
||||
} else {
|
||||
Properties::default()
|
||||
};
|
||||
|
||||
let label_text = Text::new_inline(label, font_family, 12.)
|
||||
.with_color(label_color)
|
||||
.with_style(style)
|
||||
.finish();
|
||||
|
||||
let mut left = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if let Some(color) = swatch_color {
|
||||
left.add_child(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(10.)
|
||||
.with_height(10.)
|
||||
.finish(),
|
||||
);
|
||||
left.add_child(Container::new(label_text).with_margin_left(8.).finish());
|
||||
} else {
|
||||
left.add_child(label_text);
|
||||
}
|
||||
|
||||
let credits_text = Text::new_inline(format_credits(credits), font_family, 12.)
|
||||
.with_color(value_color)
|
||||
.with_style(style)
|
||||
.finish();
|
||||
let cost_text = Text::new_inline(format_cost_cents(cost_cents), font_family, 12.)
|
||||
.with_color(value_color)
|
||||
.with_style(style)
|
||||
.finish();
|
||||
let divider = Text::new_inline("/".to_string(), font_family, 12.)
|
||||
.with_color(label_color)
|
||||
.with_style(style)
|
||||
.finish();
|
||||
|
||||
let credits_col = ConstrainedBox::new(Align::new(credits_text).right().finish())
|
||||
.with_width(60.)
|
||||
.finish();
|
||||
let cost_col = ConstrainedBox::new(Align::new(cost_text).right().finish())
|
||||
.with_width(64.)
|
||||
.finish();
|
||||
|
||||
let right = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(credits_col)
|
||||
.with_child(Container::new(divider).with_horizontal_margin(3.).finish())
|
||||
.with_child(cost_col)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., left.finish()).finish())
|
||||
.with_child(right)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "billing_cycle_usage_common_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,376 +0,0 @@
|
||||
use super::{
|
||||
aggregate_segments, filter_legacy_buckets, has_non_viewer_data, legend_cost_types, BarSegment,
|
||||
};
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
|
||||
AiCreditsUsageSource, BillingCycleUsageEntry,
|
||||
};
|
||||
|
||||
const VIEWER_UID: &str = "viewer-uid";
|
||||
const OTHER_UID: &str = "other-uid";
|
||||
|
||||
fn entry(
|
||||
subject_type: AiCreditsUsageAndCostSubjectType,
|
||||
subject_uid: Option<&str>,
|
||||
cost_type: AiCreditsUsageAndCostType,
|
||||
usage_bucket: AiCreditsUsageBucket,
|
||||
usage_source: AiCreditsUsageSource,
|
||||
credits_used: i32,
|
||||
cost_cents: i32,
|
||||
) -> BillingCycleUsageEntry {
|
||||
BillingCycleUsageEntry {
|
||||
subject_type,
|
||||
subject_uid: subject_uid.map(|s| s.to_string()),
|
||||
subject_display_name: None,
|
||||
cost_type,
|
||||
usage_bucket,
|
||||
usage_source,
|
||||
credits_used,
|
||||
cost_cents,
|
||||
}
|
||||
}
|
||||
|
||||
/// Boilerplate viewer-owned User row for predicate tests.
|
||||
fn viewer_user_entry() -> BillingCycleUsageEntry {
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_returns_false_when_entries_empty() {
|
||||
assert!(!has_non_viewer_data(&[], Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_returns_false_when_only_viewer_user_rows() {
|
||||
let entries = vec![viewer_user_entry(), viewer_user_entry()];
|
||||
assert!(!has_non_viewer_data(&entries, Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_returns_true_for_team_aggregate_row() {
|
||||
// TeamAggregate visibility represents "everyone else's usage" as a single
|
||||
// Team-typed row, even when the workspace currently has only one member
|
||||
// (e.g. a teammate left mid-cycle after incurring AI costs).
|
||||
let entries = vec![
|
||||
viewer_user_entry(),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::Team,
|
||||
None,
|
||||
AiCreditsUsageAndCostType::Aggregate,
|
||||
AiCreditsUsageBucket::Aggregate,
|
||||
AiCreditsUsageSource::Aggregate,
|
||||
500,
|
||||
300,
|
||||
),
|
||||
];
|
||||
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_returns_true_for_other_user_row() {
|
||||
// PerUserTotals / FullBreakdown emit per-user rows, so a departed teammate
|
||||
// shows up as a User entry with a non-viewer UID.
|
||||
let entries = vec![entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(OTHER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
50,
|
||||
0,
|
||||
)];
|
||||
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_returns_true_for_service_account_row() {
|
||||
let entries = vec![entry(
|
||||
AiCreditsUsageAndCostSubjectType::ServiceAccount,
|
||||
Some("sa-uid"),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Cloud,
|
||||
25,
|
||||
0,
|
||||
)];
|
||||
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_treats_missing_subject_uid_as_non_viewer() {
|
||||
// Defensive: a User row with no UID is conservatively treated as a non-
|
||||
// viewer subject so we never accidentally drop team scaffolding.
|
||||
let entries = vec![entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
None,
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
1,
|
||||
0,
|
||||
)];
|
||||
assert!(has_non_viewer_data(&entries, Some(VIEWER_UID)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_non_viewer_data_treats_missing_viewer_uid_as_non_viewer() {
|
||||
// Signed-out / unidentified viewer: any subject we can't prove belongs
|
||||
// to them counts as non-viewer data.
|
||||
let entries = vec![viewer_user_entry()];
|
||||
assert!(has_non_viewer_data(&entries, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_legacy_buckets_drops_voice_and_suggested_code_diffs_in_input_order() {
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Voice,
|
||||
AiCreditsUsageSource::Local,
|
||||
3,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Compute,
|
||||
AiCreditsUsageSource::Local,
|
||||
5,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::SuggestedCodeDiffs,
|
||||
AiCreditsUsageSource::Local,
|
||||
7,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::Aggregate,
|
||||
AiCreditsUsageBucket::Aggregate,
|
||||
AiCreditsUsageSource::Aggregate,
|
||||
100,
|
||||
50,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Platform,
|
||||
AiCreditsUsageSource::Cloud,
|
||||
2,
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
let filtered = filter_legacy_buckets(&entries);
|
||||
|
||||
let kept_buckets: Vec<_> = filtered.iter().map(|e| e.usage_bucket.clone()).collect();
|
||||
assert_eq!(
|
||||
kept_buckets,
|
||||
vec![
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageBucket::Compute,
|
||||
AiCreditsUsageBucket::Aggregate,
|
||||
AiCreditsUsageBucket::Platform,
|
||||
],
|
||||
"expected Voice + SuggestedCodeDiffs dropped while preserving the rest in input order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_segments_merges_dupes_drops_zeros_and_sorts() {
|
||||
let entries = [
|
||||
// Same (BonusGrant, Compute) appears twice across different sources;
|
||||
// should merge into one segment.
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BonusGrant,
|
||||
AiCreditsUsageBucket::Compute,
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
5,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BonusGrant,
|
||||
AiCreditsUsageBucket::Compute,
|
||||
AiCreditsUsageSource::Cloud,
|
||||
7,
|
||||
3,
|
||||
),
|
||||
// BaseLimit/Ai — should sort before any BonusGrant entry.
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
20,
|
||||
0,
|
||||
),
|
||||
// Zero-credit entry: must be dropped before totals are computed (so
|
||||
// the stray cost_cents don't leak into the row total).
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
0,
|
||||
42,
|
||||
),
|
||||
];
|
||||
|
||||
let (segments, total_credits, total_cost_cents) = aggregate_segments(entries.iter());
|
||||
|
||||
let key = |s: &BarSegment| (s.cost_type.clone(), s.usage_bucket.clone());
|
||||
let keys: Vec<_> = segments.iter().map(key).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
(
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai
|
||||
),
|
||||
(
|
||||
AiCreditsUsageAndCostType::BonusGrant,
|
||||
AiCreditsUsageBucket::Compute
|
||||
),
|
||||
],
|
||||
"expected BaseLimit/Ai before BonusGrant/Compute, Payg zero-credit dropped"
|
||||
);
|
||||
|
||||
let bonus = &segments[1];
|
||||
assert_eq!(bonus.credits, 17, "10 + 7 merged credits");
|
||||
assert_eq!(bonus.cost_cents, 8, "5 + 3 merged cost cents");
|
||||
|
||||
// Totals are summed *after* the zero-credit segment is dropped, so the
|
||||
// stray 42 cents on the Payg/Ai entry must not appear here.
|
||||
assert_eq!(total_credits, 20 + 17);
|
||||
assert_eq!(total_cost_cents, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_cost_types_excludes_zero_credit_bucket() {
|
||||
// Regression: a base-limit row with no usage must not surface "Base" in
|
||||
// the legend while only Pay-as-you-go credits were actually spent.
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
50,
|
||||
120,
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
legend_cost_types(&entries),
|
||||
vec![AiCreditsUsageAndCostType::Payg],
|
||||
"zero-credit BaseLimit row must be dropped from the legend"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_cost_types_includes_used_buckets_in_display_order() {
|
||||
// Buckets with real usage appear in the canonical legend order regardless
|
||||
// of input order (Payg listed before BaseLimit here).
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
5,
|
||||
10,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Ai,
|
||||
AiCreditsUsageSource::Local,
|
||||
8,
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
legend_cost_types(&entries),
|
||||
vec![
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageAndCostType::Payg,
|
||||
],
|
||||
"used buckets should render in canonical order, not input order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_cost_types_excludes_legacy_only_buckets() {
|
||||
// Voice / SuggestedCodeDiffs usage is written as BaseLimit credits but is
|
||||
// dropped from the bars; the legend must match and not show "Base".
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::Voice,
|
||||
AiCreditsUsageSource::Local,
|
||||
12,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageAndCostType::BaseLimit,
|
||||
AiCreditsUsageBucket::SuggestedCodeDiffs,
|
||||
AiCreditsUsageSource::Local,
|
||||
4,
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
assert!(
|
||||
legend_cost_types(&entries).is_empty(),
|
||||
"legacy-only base-limit usage must not surface any legend bucket"
|
||||
);
|
||||
}
|
||||
@@ -1,838 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use itertools::Itertools as _;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow,
|
||||
Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
|
||||
Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{AppContext, Element, EventContext, SingletonEntity};
|
||||
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{
|
||||
aggregate_segments, cost_type_color, format_cost_cents, format_credits,
|
||||
render_breakdown_tooltip, render_section_subheader, BarSegment, BillingUsageMouseStates,
|
||||
ROW_BORDER_RADIUS, ROW_BORDER_WIDTH, TOOLTIP_GAP,
|
||||
};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
|
||||
AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity,
|
||||
Workspace, WorkspaceMember,
|
||||
};
|
||||
|
||||
const BAR_HEIGHT: f32 = 8.;
|
||||
const MIN_FILL_RATIO: f32 = 0.05;
|
||||
/// Size of the leading icons in the row credit cluster (coin + credit-card).
|
||||
const ROW_ICON_SIZE: f32 = 12.;
|
||||
/// Inner radius so the bar's curve sits flush against the card's inner border.
|
||||
const BAR_CORNER_RADIUS: f32 = ROW_BORDER_RADIUS - ROW_BORDER_WIDTH;
|
||||
const ROW_PADDING: f32 = 12.;
|
||||
|
||||
const SELF_OWN_KEY: &str = "__self_own__";
|
||||
const OTHER_MEMBERS_KEY: &str = "__other_members__";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SourceFilter {
|
||||
#[default]
|
||||
All,
|
||||
Local,
|
||||
Cloud,
|
||||
}
|
||||
|
||||
impl SourceFilter {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SourceFilter::All => "All",
|
||||
SourceFilter::Local => "Local",
|
||||
SourceFilter::Cloud => "Cloud",
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(self, source: &AiCreditsUsageSource) -> bool {
|
||||
match self {
|
||||
SourceFilter::All => true,
|
||||
SourceFilter::Local => *source == AiCreditsUsageSource::Local,
|
||||
SourceFilter::Cloud => *source == AiCreditsUsageSource::Cloud,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated usage for one subject (or the synthetic team aggregate).
|
||||
#[derive(Debug)]
|
||||
pub struct MemberUsageRow {
|
||||
pub subject_type: AiCreditsUsageAndCostSubjectType,
|
||||
pub subject_key: String,
|
||||
/// Used to deep-link `ServiceAccount` rows to their Oz agent page.
|
||||
pub subject_uid: Option<String>,
|
||||
pub display_name: String,
|
||||
pub total_credits: i64,
|
||||
pub total_cost_cents: i64,
|
||||
/// Sorted by cost-type then bucket order; zero-credit entries dropped.
|
||||
pub segments: Vec<BarSegment>,
|
||||
/// Denominator the row's stacked bar fills against.
|
||||
pub bar_max_credits: i64,
|
||||
}
|
||||
|
||||
fn viewer_identity(app: &AppContext) -> (Option<String>, String) {
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let viewer_uid = auth_state.user_id().map(|uid| uid.as_string());
|
||||
let display_name = auth_state
|
||||
.display_name()
|
||||
.or_else(|| auth_state.username_for_display())
|
||||
.or_else(|| auth_state.user_email())
|
||||
.unwrap_or_else(|| "Your usage".to_string());
|
||||
(viewer_uid, display_name)
|
||||
}
|
||||
|
||||
struct GroupedSubjectUsage {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType,
|
||||
display_name: String,
|
||||
entries: Vec<BillingCycleUsageEntry>,
|
||||
}
|
||||
|
||||
impl MemberUsageRow {
|
||||
fn for_viewer(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
viewer_uid: Option<&str>,
|
||||
viewer_display_name: String,
|
||||
source_filter: SourceFilter,
|
||||
) -> Self {
|
||||
let viewer_entries = entries
|
||||
.iter()
|
||||
.filter(|e| source_filter.matches(&e.usage_source))
|
||||
// Defensive: positive-attribute to the viewer only.
|
||||
.filter(|e| match (viewer_uid, e.subject_uid.as_deref()) {
|
||||
(Some(uid), Some(entry_uid)) => uid == entry_uid,
|
||||
_ => false,
|
||||
})
|
||||
.collect_vec();
|
||||
let (segments, total_credits, total_cost_cents) =
|
||||
aggregate_segments(viewer_entries.iter().copied());
|
||||
|
||||
Self {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType::User,
|
||||
subject_key: SELF_OWN_KEY.to_string(),
|
||||
subject_uid: viewer_uid.map(str::to_string),
|
||||
display_name: viewer_display_name,
|
||||
total_credits,
|
||||
total_cost_cents,
|
||||
segments,
|
||||
bar_max_credits: total_credits.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Viewer row built from a raw used-credits count, with no segment
|
||||
/// breakdown. For callers that only have `AIRequestUsageModel`-style
|
||||
/// data (no `billing_cycle_usage` entries / no workspace data).
|
||||
fn for_viewer_from_total(
|
||||
viewer_uid: Option<String>,
|
||||
viewer_display_name: String,
|
||||
used: i64,
|
||||
) -> Self {
|
||||
let segments = if used > 0 {
|
||||
vec![BarSegment {
|
||||
cost_type: AiCreditsUsageAndCostType::BaseLimit,
|
||||
usage_bucket: AiCreditsUsageBucket::Ai,
|
||||
credits: used,
|
||||
cost_cents: 0,
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType::User,
|
||||
subject_key: SELF_OWN_KEY.to_string(),
|
||||
subject_uid: viewer_uid,
|
||||
display_name: viewer_display_name,
|
||||
total_credits: used,
|
||||
total_cost_cents: 0,
|
||||
segments,
|
||||
bar_max_credits: used.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic "Other members" aggregate row used by TeamAggregate
|
||||
/// visibility — represents everyone except the viewer.
|
||||
fn for_other_members(entries: &[BillingCycleUsageEntry]) -> Self {
|
||||
let team_entries = entries
|
||||
.iter()
|
||||
.filter(|e| e.subject_type == AiCreditsUsageAndCostSubjectType::Team);
|
||||
let (segments, total_credits, total_cost_cents) = aggregate_segments(team_entries);
|
||||
|
||||
Self {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType::Team,
|
||||
subject_key: OTHER_MEMBERS_KEY.to_string(),
|
||||
subject_uid: None,
|
||||
display_name: "Other members".to_string(),
|
||||
total_credits,
|
||||
total_cost_cents,
|
||||
segments,
|
||||
bar_max_credits: total_credits.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-member rows for `PerUserTotals` / `FullBreakdown` visibility.
|
||||
/// Iterates the workspace member list so zero-usage members still
|
||||
/// get a row. Service accounts and other non-member subjects surface
|
||||
/// as extra rows at the bottom, sorted by total credits desc.
|
||||
fn for_each_member(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
members: &[WorkspaceMember],
|
||||
source_filter: SourceFilter,
|
||||
) -> Vec<Self> {
|
||||
// Group entries by subject for joining against the member list below.
|
||||
let mut grouped: HashMap<String, GroupedSubjectUsage> = HashMap::new();
|
||||
let mut unknown_counter = 0usize;
|
||||
|
||||
for entry in entries
|
||||
.iter()
|
||||
.filter(|e| e.subject_type != AiCreditsUsageAndCostSubjectType::Team)
|
||||
{
|
||||
if !source_filter.matches(&entry.usage_source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = match entry.subject_uid.as_deref() {
|
||||
Some(uid) => format!("{:?}:{uid}", entry.subject_type),
|
||||
None => {
|
||||
unknown_counter += 1;
|
||||
format!("{:?}:unknown-{unknown_counter}", entry.subject_type)
|
||||
}
|
||||
};
|
||||
let group = grouped.entry(key).or_insert_with(|| GroupedSubjectUsage {
|
||||
subject_type: entry.subject_type.clone(),
|
||||
display_name: entry
|
||||
.subject_display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
group.entries.push(entry.clone());
|
||||
}
|
||||
|
||||
let mut rows: Vec<Self> = Vec::with_capacity(members.len());
|
||||
|
||||
// One row per workspace member, including zero-usage members.
|
||||
let mut seen_keys: std::collections::HashSet<String> = Default::default();
|
||||
for member in members {
|
||||
let key = format!(
|
||||
"{:?}:{}",
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
member.uid.as_str()
|
||||
);
|
||||
seen_keys.insert(key.clone());
|
||||
|
||||
let (segments, total_credits, total_cost_cents) = match grouped.remove(&key) {
|
||||
Some(group) => aggregate_segments(group.entries.iter()),
|
||||
None => (Vec::new(), 0, 0),
|
||||
};
|
||||
|
||||
rows.push(Self {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType::User,
|
||||
subject_key: key,
|
||||
subject_uid: Some(member.uid.as_str().to_string()),
|
||||
display_name: member.email.clone(),
|
||||
total_credits,
|
||||
total_cost_cents,
|
||||
segments,
|
||||
bar_max_credits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Subjects not in the member list (typically service accounts) render after.
|
||||
for (key, group) in grouped {
|
||||
if seen_keys.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
// All entries in a group share the same subject_uid by construction
|
||||
// (it's part of the grouping key), so first.is representative.
|
||||
let subject_uid = group.entries.first().and_then(|e| e.subject_uid.clone());
|
||||
let (segments, total_credits, total_cost_cents) =
|
||||
aggregate_segments(group.entries.iter());
|
||||
rows.push(Self {
|
||||
subject_type: group.subject_type,
|
||||
subject_key: key,
|
||||
subject_uid,
|
||||
display_name: group.display_name,
|
||||
total_credits,
|
||||
total_cost_cents,
|
||||
segments,
|
||||
bar_max_credits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by total credits desc, stable by subject_key.
|
||||
rows.sort_by(|a, b| {
|
||||
b.total_credits
|
||||
.cmp(&a.total_credits)
|
||||
.then_with(|| a.subject_key.cmp(&b.subject_key))
|
||||
});
|
||||
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
fn build_rows(
|
||||
workspace: &Workspace,
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
visibility: &UsageVisibility,
|
||||
source_filter: SourceFilter,
|
||||
app: &AppContext,
|
||||
) -> Vec<MemberUsageRow> {
|
||||
let mut rows: Vec<MemberUsageRow> = match visibility.granularity {
|
||||
UsageVisibilityGranularity::OwnOnly => {
|
||||
let (viewer_uid, display_name) = viewer_identity(app);
|
||||
vec![MemberUsageRow::for_viewer(
|
||||
entries,
|
||||
viewer_uid.as_deref(),
|
||||
display_name,
|
||||
source_filter,
|
||||
)]
|
||||
}
|
||||
UsageVisibilityGranularity::TeamAggregate => {
|
||||
// Force SourceFilter::All — TeamAggregate has no toggle.
|
||||
let (viewer_uid, display_name) = viewer_identity(app);
|
||||
let mut rows = vec![MemberUsageRow::for_viewer(
|
||||
entries,
|
||||
viewer_uid.as_deref(),
|
||||
display_name,
|
||||
SourceFilter::All,
|
||||
)];
|
||||
rows.push(MemberUsageRow::for_other_members(entries));
|
||||
rows
|
||||
}
|
||||
UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown => {
|
||||
MemberUsageRow::for_each_member(entries, &workspace.members, source_filter)
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(
|
||||
visibility.granularity,
|
||||
UsageVisibilityGranularity::PerUserTotals | UsageVisibilityGranularity::FullBreakdown
|
||||
) {
|
||||
let top = rows
|
||||
.iter()
|
||||
.map(|r| r.total_credits)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
for row in &mut rows {
|
||||
row.bar_max_credits = top;
|
||||
}
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// True if any entry is cloud-sourced; gates the source filter toggle.
|
||||
pub fn has_cloud_usage(entries: &[BillingCycleUsageEntry]) -> bool {
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.usage_source == AiCreditsUsageSource::Cloud)
|
||||
}
|
||||
|
||||
fn render_stacked_bar(
|
||||
segments: &[BarSegment],
|
||||
total_credits: i64,
|
||||
team_max_credits: i64,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let track_bg = theme.surface_overlay_1();
|
||||
let corner = Radius::Pixels(BAR_CORNER_RADIUS);
|
||||
|
||||
if team_max_credits == 0 || total_credits == 0 || segments.is_empty() {
|
||||
// Empty track, top-rounded on both ends.
|
||||
return ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background(track_bg)
|
||||
.with_corner_radius(CornerRadius::with_top(corner))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(BAR_HEIGHT)
|
||||
.finish();
|
||||
}
|
||||
|
||||
let fill_ratio = (total_credits as f32 / team_max_credits as f32).clamp(MIN_FILL_RATIO, 1.0);
|
||||
let unfill_ratio = 1.0 - fill_ratio;
|
||||
let has_unfill = unfill_ratio > 0.0;
|
||||
let last_segment_idx = segments.len() - 1;
|
||||
|
||||
// One Expanded per segment, weighted by share of total_credits. First/last
|
||||
// segment get rounded top corners (last only if no muted tail).
|
||||
let mut filled = Flex::row();
|
||||
for (idx, seg) in segments.iter().enumerate() {
|
||||
let weight = seg.credits as f32 / total_credits as f32;
|
||||
if weight <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let is_first = idx == 0;
|
||||
let is_last_visible = idx == last_segment_idx && !has_unfill;
|
||||
let segment_corner = match (is_first, is_last_visible) {
|
||||
(true, true) => CornerRadius::with_top(corner),
|
||||
(true, false) => CornerRadius::with_top_left(corner),
|
||||
(false, true) => CornerRadius::with_top_right(corner),
|
||||
(false, false) => CornerRadius::default(),
|
||||
};
|
||||
filled.add_child(
|
||||
Expanded::new(
|
||||
weight,
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(cost_type_color(&seg.cost_type))
|
||||
.with_corner_radius(segment_corner)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut bar = Flex::row();
|
||||
bar.add_child(Expanded::new(fill_ratio, filled.finish()).finish());
|
||||
if has_unfill {
|
||||
bar.add_child(
|
||||
Expanded::new(
|
||||
unfill_ratio,
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background(track_bg)
|
||||
.with_corner_radius(CornerRadius::with_top_right(corner))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox::new(bar.finish())
|
||||
.with_height(BAR_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Per-cost-type tooltip breakdown with a "Total usage" footer.
|
||||
fn render_usage_tooltip_content(row: &MemberUsageRow, appearance: &Appearance) -> Box<dyn Element> {
|
||||
render_breakdown_tooltip(
|
||||
&row.segments,
|
||||
row.total_credits,
|
||||
row.total_cost_cents,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Small text-only tooltip surfaced on hover of the service-account info
|
||||
/// icon. Mirrors the visual treatment of `render_aggregate_legend_tooltip`.
|
||||
fn render_service_account_info_tooltip(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let text = Text::new_inline(
|
||||
"This is an automated agent on your team.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish();
|
||||
Container::new(text)
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_drop_shadow(
|
||||
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
|
||||
.with_offset(vec2f(0., 4.)),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders one row card (stacked bar + name/totals).
|
||||
fn render_row_card(
|
||||
row: &MemberUsageRow,
|
||||
team_max_credits: i64,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let card_bg = theme.background().into_solid();
|
||||
let main = blended_colors::text_main(theme, card_bg);
|
||||
|
||||
let bar = render_stacked_bar(
|
||||
&row.segments,
|
||||
row.total_credits,
|
||||
team_max_credits,
|
||||
appearance,
|
||||
);
|
||||
|
||||
let is_service_account = matches!(
|
||||
row.subject_type,
|
||||
AiCreditsUsageAndCostSubjectType::ServiceAccount
|
||||
);
|
||||
// Service accounts with a known UID deep-link to their Oz agent page,
|
||||
// mirroring the web admin panel's `getOzAgentHref` behavior.
|
||||
let agent_href = if is_service_account {
|
||||
row.subject_uid.as_deref().map(|uid| {
|
||||
format!(
|
||||
"{}/agents/{}",
|
||||
ChannelState::oz_root_url(),
|
||||
urlencoding::encode(uid)
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let display_name_element: Box<dyn Element> = if let Some(href) = agent_href {
|
||||
let link_state =
|
||||
mouse_states.tooltip_mouse_state(&format!("{}__agent_link", row.subject_key));
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(row.display_name.clone(), Some(href), None, link_state)
|
||||
.build()
|
||||
.finish()
|
||||
} else {
|
||||
Text::new_inline(
|
||||
row.display_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let mut name_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(display_name_element);
|
||||
|
||||
if is_service_account {
|
||||
let info_state =
|
||||
mouse_states.tooltip_mouse_state(&format!("{}__agent_info", row.subject_key));
|
||||
let info_icon = Hoverable::new(info_state, move |state| {
|
||||
let info_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
let icon = ConstrainedBox::new(Icon::Info.to_warpui_icon(info_color).finish())
|
||||
.with_width(ROW_ICON_SIZE)
|
||||
.with_height(ROW_ICON_SIZE)
|
||||
.finish();
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(icon);
|
||||
if state.is_hovered() {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_service_account_info_tooltip(appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -TOOLTIP_GAP),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish();
|
||||
name_row.add_child(Container::new(info_icon).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
let credits_text = Text::new_inline(
|
||||
format_credits(row.total_credits),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main)
|
||||
.finish();
|
||||
let cost_text = Text::new_inline(
|
||||
format_cost_cents(row.total_cost_cents),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(main)
|
||||
.finish();
|
||||
let icon_color = theme.sub_text_color(theme.background());
|
||||
let coin_icon = ConstrainedBox::new(Icon::Credits.to_warpui_icon(icon_color).finish())
|
||||
.with_width(ROW_ICON_SIZE)
|
||||
.with_height(ROW_ICON_SIZE)
|
||||
.finish();
|
||||
let card_icon = ConstrainedBox::new(Icon::CreditCard.to_warpui_icon(icon_color).finish())
|
||||
.with_width(ROW_ICON_SIZE)
|
||||
.with_height(ROW_ICON_SIZE)
|
||||
.finish();
|
||||
let credits_cluster = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(coin_icon)
|
||||
.with_child(Container::new(credits_text).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
let cost_cluster = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(card_icon)
|
||||
.with_child(Container::new(cost_text).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
|
||||
let credits_and_cost = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(credits_cluster)
|
||||
.with_child(Container::new(cost_cluster).with_margin_left(6.).finish())
|
||||
.finish();
|
||||
|
||||
let body = Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., name_row.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(credits_and_cost)
|
||||
.with_margin_left(16.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(ROW_PADDING)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(bar)
|
||||
.with_child(body)
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(card_bg)
|
||||
.with_border(Border::all(ROW_BORDER_WIDTH).with_border_color(theme.outline().into_solid()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_BORDER_RADIUS)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Row card wrapped in a Hoverable that opens the breakdown tooltip.
|
||||
fn render_member_row(
|
||||
row: &MemberUsageRow,
|
||||
team_max_credits: i64,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// No segments => no tooltip needed.
|
||||
if row.segments.is_empty() {
|
||||
return render_row_card(row, team_max_credits, mouse_states, appearance);
|
||||
}
|
||||
|
||||
// The info icon sits inside the row card, so hovering it would otherwise
|
||||
// trigger both this row's breakdown tooltip and the icon's own tooltip
|
||||
// on top of each other. Pull the icon's hover state up so we can
|
||||
// suppress the breakdown tooltip while the icon is hovered.
|
||||
let info_state = matches!(
|
||||
row.subject_type,
|
||||
AiCreditsUsageAndCostSubjectType::ServiceAccount
|
||||
)
|
||||
.then(|| mouse_states.tooltip_mouse_state(&format!("{}__agent_info", row.subject_key)));
|
||||
|
||||
Hoverable::new(tooltip_mouse_state, move |state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(render_row_card(
|
||||
row,
|
||||
team_max_credits,
|
||||
mouse_states,
|
||||
appearance,
|
||||
));
|
||||
|
||||
let info_hovered = info_state
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.lock().is_ok_and(|guard| guard.is_hovered()));
|
||||
|
||||
if state.is_hovered() && !info_hovered {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_usage_tooltip_content(row, appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -TOOLTIP_GAP),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub type FilterChangeFn = std::sync::Arc<dyn Fn(SourceFilter, &mut EventContext) + 'static>;
|
||||
|
||||
/// All / Local / Cloud pill toggle.
|
||||
fn render_source_filter_toggle(
|
||||
current: SourceFilter,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
on_change: FilterChangeFn,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let bg = theme.surface_1();
|
||||
let main = blended_colors::text_main(theme, bg);
|
||||
let sub = blended_colors::text_sub(theme, bg);
|
||||
|
||||
let options: [(SourceFilter, MouseStateHandle); 3] = [
|
||||
(SourceFilter::All, mouse_states.filter_all.clone()),
|
||||
(SourceFilter::Local, mouse_states.filter_local.clone()),
|
||||
(SourceFilter::Cloud, mouse_states.filter_cloud.clone()),
|
||||
];
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
for (filter, mouse_state) in options {
|
||||
let label = filter.label();
|
||||
let is_selected = filter == current;
|
||||
let fg = if is_selected { main } else { sub };
|
||||
let font_family = appearance.ui_font_family();
|
||||
let on_change = on_change.clone();
|
||||
|
||||
let cell = Hoverable::new(mouse_state, move |_state| {
|
||||
let mut cell = Container::new(
|
||||
Text::new_inline(label, font_family, 11.)
|
||||
.with_color(fg)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(10.)
|
||||
.with_vertical_padding(4.);
|
||||
if is_selected {
|
||||
cell = cell.with_background(theme.surface_overlay_1());
|
||||
}
|
||||
cell.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
on_change(filter, ctx);
|
||||
})
|
||||
.finish();
|
||||
|
||||
row.add_child(cell);
|
||||
}
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_border(Border::all(1.).with_border_color(theme.surface_3().into_solid()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_own_usage_with_workspace_row(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let (viewer_uid, display_name) = viewer_identity(app);
|
||||
let row = MemberUsageRow::for_viewer(
|
||||
entries,
|
||||
viewer_uid.as_deref(),
|
||||
display_name,
|
||||
SourceFilter::All,
|
||||
);
|
||||
render_member_row_list(std::slice::from_ref(&row), mouse_states, appearance)
|
||||
}
|
||||
|
||||
pub fn render_own_usage_solo_row(
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let (viewer_uid, display_name) = viewer_identity(app);
|
||||
let model = AIRequestUsageModel::as_ref(app);
|
||||
let row = MemberUsageRow::for_viewer_from_total(
|
||||
viewer_uid,
|
||||
display_name,
|
||||
model.requests_used() as i64,
|
||||
);
|
||||
render_member_row_list(std::slice::from_ref(&row), mouse_states, appearance)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_rows(
|
||||
workspace: &Workspace,
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
visibility: &UsageVisibility,
|
||||
source_filter: SourceFilter,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
on_filter_change: FilterChangeFn,
|
||||
) -> Box<dyn Element> {
|
||||
let rows = build_rows(workspace, entries, visibility, source_filter, app);
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(8.);
|
||||
if let Some(header) = render_member_header(
|
||||
visibility,
|
||||
entries,
|
||||
source_filter,
|
||||
mouse_states,
|
||||
appearance,
|
||||
on_filter_change,
|
||||
) {
|
||||
column.add_child(header);
|
||||
}
|
||||
column.add_child(render_member_row_list(&rows, mouse_states, appearance));
|
||||
column.finish()
|
||||
}
|
||||
|
||||
fn render_member_header(
|
||||
visibility: &UsageVisibility,
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
source_filter: SourceFilter,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
on_filter_change: FilterChangeFn,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let show_toggle = visibility.granularity == UsageVisibilityGranularity::FullBreakdown
|
||||
&& has_cloud_usage(entries);
|
||||
|
||||
let subheader = render_section_subheader("Members", appearance);
|
||||
let header = if show_toggle {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(subheader)
|
||||
.with_child(render_source_filter_toggle(
|
||||
source_filter,
|
||||
mouse_states,
|
||||
appearance,
|
||||
on_filter_change,
|
||||
))
|
||||
.finish()
|
||||
} else {
|
||||
subheader
|
||||
};
|
||||
|
||||
Some(Container::new(header).with_margin_bottom(8.).finish())
|
||||
}
|
||||
|
||||
fn render_member_row_list(
|
||||
rows: &[MemberUsageRow],
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(8.);
|
||||
for row in rows {
|
||||
let tooltip_state = mouse_states.tooltip_mouse_state(&row.subject_key);
|
||||
column.add_child(render_member_row(
|
||||
row,
|
||||
row.bar_max_credits,
|
||||
tooltip_state,
|
||||
mouse_states,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
column.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "billing_cycle_usage_rows_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,139 +0,0 @@
|
||||
use super::{MemberUsageRow, SourceFilter};
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
|
||||
AiCreditsUsageSource, BillingCycleUsageEntry,
|
||||
};
|
||||
|
||||
const VIEWER_UID: &str = "viewer-uid";
|
||||
const OTHER_UID: &str = "other-uid";
|
||||
|
||||
fn entry(
|
||||
subject_type: AiCreditsUsageAndCostSubjectType,
|
||||
subject_uid: Option<&str>,
|
||||
usage_source: AiCreditsUsageSource,
|
||||
credits_used: i32,
|
||||
cost_cents: i32,
|
||||
) -> BillingCycleUsageEntry {
|
||||
BillingCycleUsageEntry {
|
||||
subject_type,
|
||||
subject_uid: subject_uid.map(|s| s.to_string()),
|
||||
subject_display_name: None,
|
||||
cost_type: AiCreditsUsageAndCostType::BaseLimit,
|
||||
usage_bucket: AiCreditsUsageBucket::Ai,
|
||||
usage_source,
|
||||
credits_used,
|
||||
cost_cents,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_own_usage_row_drops_team_subject_entries() {
|
||||
// Team-aggregate rows belong to "everyone else" by construction; they
|
||||
// must never contribute to the viewer's own row totals.
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
5,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::Team,
|
||||
None,
|
||||
AiCreditsUsageSource::Aggregate,
|
||||
999,
|
||||
999,
|
||||
),
|
||||
];
|
||||
let row = MemberUsageRow::for_viewer(
|
||||
&entries,
|
||||
Some(VIEWER_UID),
|
||||
"viewer".to_string(),
|
||||
SourceFilter::All,
|
||||
);
|
||||
assert_eq!(row.total_credits, 10);
|
||||
assert_eq!(row.total_cost_cents, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_own_usage_row_drops_other_users_entries() {
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(OTHER_UID),
|
||||
AiCreditsUsageSource::Local,
|
||||
999,
|
||||
999,
|
||||
),
|
||||
];
|
||||
let row = MemberUsageRow::for_viewer(
|
||||
&entries,
|
||||
Some(VIEWER_UID),
|
||||
"viewer".to_string(),
|
||||
SourceFilter::All,
|
||||
);
|
||||
assert_eq!(row.total_credits, 10);
|
||||
assert_eq!(row.total_cost_cents, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_own_usage_row_local_filter_drops_cloud_entries() {
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Cloud,
|
||||
20,
|
||||
0,
|
||||
),
|
||||
];
|
||||
let row = MemberUsageRow::for_viewer(
|
||||
&entries,
|
||||
Some(VIEWER_UID),
|
||||
"viewer".to_string(),
|
||||
SourceFilter::Local,
|
||||
);
|
||||
assert_eq!(row.total_credits, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_own_usage_row_cloud_filter_drops_local_entries() {
|
||||
let entries = vec![
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Local,
|
||||
10,
|
||||
0,
|
||||
),
|
||||
entry(
|
||||
AiCreditsUsageAndCostSubjectType::User,
|
||||
Some(VIEWER_UID),
|
||||
AiCreditsUsageSource::Cloud,
|
||||
20,
|
||||
0,
|
||||
),
|
||||
];
|
||||
let row = MemberUsageRow::for_viewer(
|
||||
&entries,
|
||||
Some(VIEWER_UID),
|
||||
"viewer".to_string(),
|
||||
SourceFilter::Cloud,
|
||||
);
|
||||
assert_eq!(row.total_credits, 20);
|
||||
}
|
||||
@@ -1,801 +0,0 @@
|
||||
use chrono::{DateTime, Datelike, Local, Utc};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DropShadow, Empty, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkLens,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::auth::{AuthManager, AuthStateProvider};
|
||||
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
|
||||
use crate::settings_view::admin_actions::AdminActions;
|
||||
use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{
|
||||
filter_legacy_buckets, has_non_viewer_data, legend_cost_types, BillingUsageMouseStates,
|
||||
};
|
||||
use crate::settings_view::billing_and_usage::billing_cycle_usage_rows::{
|
||||
has_cloud_usage, render_own_usage_solo_row, render_own_usage_with_workspace_row, render_rows,
|
||||
SourceFilter,
|
||||
};
|
||||
use crate::settings_view::billing_and_usage::billing_cycle_usage_team_totals::render_team_totals_block;
|
||||
use crate::settings_view::billing_and_usage_page_v2::{
|
||||
AGGREGATE_CREDITS_DOT_COLOR, AMBIENT_CREDITS_DOT_COLOR, BASE_CREDITS_DOT_COLOR,
|
||||
BONUS_CREDITS_DOT_COLOR, PAYG_CREDITS_DOT_COLOR,
|
||||
};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::update_manager::TeamUpdateManager;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostType, BillingCycleUsageSummary, MaxPriorCycles, UsageVisibility,
|
||||
UsageVisibilityGranularity, Workspace,
|
||||
};
|
||||
|
||||
const HEADER_FONT_SIZE: f32 = 16.;
|
||||
const LEGEND_DOT_SIZE: f32 = 8.;
|
||||
|
||||
pub struct BillingCycleUsageSectionView {
|
||||
selected_period_end: Option<DateTime<Utc>>,
|
||||
period_selector_mouse_state: MouseStateHandle,
|
||||
aggregate_legend_mouse_state: MouseStateHandle,
|
||||
period_menu: ViewHandle<Menu<BillingCycleUsageAction>>,
|
||||
period_menu_open: bool,
|
||||
source_filter: SourceFilter,
|
||||
row_mouse_states: BillingUsageMouseStates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BillingCycleUsageAction {
|
||||
SelectPeriod(Option<DateTime<Utc>>),
|
||||
TogglePeriodMenu,
|
||||
ChangeSourceFilter(SourceFilter),
|
||||
OpenUpgrade,
|
||||
OpenAdminPanel,
|
||||
}
|
||||
|
||||
impl Entity for BillingCycleUsageSectionView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl BillingCycleUsageSectionView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, _, ctx| {
|
||||
me.reconcile_selected_period(ctx);
|
||||
// If the period menu is open while the workspace or usage data
|
||||
// changes, the menu's items become stale and clicking one could
|
||||
// select a period_end that no longer exists in the new data
|
||||
// (which `current_summary` would then fail to resolve). Rebuild
|
||||
// the items in-place so the menu always reflects the live data.
|
||||
if me.period_menu_open {
|
||||
me.refresh_period_menu_items(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, _, ctx| {
|
||||
ctx.notify()
|
||||
});
|
||||
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, _, ctx| ctx.notify());
|
||||
ctx.subscribe_to_model(&TeamUpdateManager::handle(ctx), |_, _, _, ctx| ctx.notify());
|
||||
|
||||
// `prevent_interaction_with_other_elements` so a click on the
|
||||
// trigger button while the menu is open is consumed by the menu's
|
||||
// outside-click dismiss handler — without it, the trigger also
|
||||
// received the click and immediately re-toggled the menu open.
|
||||
let period_menu = ctx.add_typed_action_view(|_| {
|
||||
Menu::new()
|
||||
.with_drop_shadow()
|
||||
.prevent_interaction_with_other_elements()
|
||||
});
|
||||
ctx.subscribe_to_view(&period_menu, |me, _, event, ctx| {
|
||||
if let menu::Event::Close { .. } = event {
|
||||
me.period_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
selected_period_end: None,
|
||||
period_selector_mouse_state: MouseStateHandle::default(),
|
||||
aggregate_legend_mouse_state: MouseStateHandle::default(),
|
||||
period_menu,
|
||||
period_menu_open: false,
|
||||
source_filter: SourceFilter::default(),
|
||||
row_mouse_states: BillingUsageMouseStates::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_viewer_email(app: &AppContext) -> Option<String> {
|
||||
AuthStateProvider::as_ref(app).get().user_email()
|
||||
}
|
||||
|
||||
fn viewer_is_admin(app: &AppContext) -> bool {
|
||||
let Some(team) = UserWorkspaces::as_ref(app).current_team() else {
|
||||
return false;
|
||||
};
|
||||
Self::resolved_viewer_email(app)
|
||||
.as_deref()
|
||||
.is_some_and(|email| team.has_admin_permissions(email))
|
||||
}
|
||||
|
||||
fn current_summary<'a>(
|
||||
&self,
|
||||
workspace: &'a Workspace,
|
||||
) -> Option<&'a BillingCycleUsageSummary> {
|
||||
let summaries = &workspace.billing_cycle_usage.as_ref()?.summaries;
|
||||
match self.selected_period_end {
|
||||
Some(end) => summaries.iter().find(|s| s.period_end == end),
|
||||
None => summaries.first(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_selected_period(&mut self, ctx: &AppContext) {
|
||||
let Some(selected) = self.selected_period_end else {
|
||||
return;
|
||||
};
|
||||
let still_present = UserWorkspaces::as_ref(ctx)
|
||||
.current_workspace()
|
||||
.and_then(|ws| ws.billing_cycle_usage.as_ref())
|
||||
.map(|data| data.summaries.iter().any(|s| s.period_end == selected))
|
||||
.unwrap_or(false);
|
||||
if !still_present {
|
||||
self.selected_period_end = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the "Team" block + "Members" subheader should render. We
|
||||
/// hide them when the viewer has no team data to show: `members.len()
|
||||
/// > 1` covers the common multi-member case; `has_non_viewer_data`
|
||||
/// catches the edge case where the roster shrank to one after a
|
||||
/// teammate left mid-cycle but their usage is still attributed against
|
||||
/// this cycle. Together they keep solo teams from showing orphan
|
||||
/// scaffolding without dropping legitimate team data on departure.
|
||||
///
|
||||
/// Note: per the backend invariant `VIS != OwnOnly => viewer is admin`,
|
||||
/// so we don't need a separate admin gate here.
|
||||
fn shows_team_section(&self, workspace: &Workspace, app: &AppContext) -> bool {
|
||||
let visibility = workspace.resolve_usage_visibility(Self::viewer_is_admin(app));
|
||||
if visibility.granularity == UsageVisibilityGranularity::OwnOnly {
|
||||
return false;
|
||||
}
|
||||
let entries = filter_legacy_buckets(
|
||||
self.current_summary(workspace)
|
||||
.map(|s| s.entries.as_slice())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let viewer_uid = AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.user_id()
|
||||
.map(|uid| uid.as_string());
|
||||
workspace.members.len() > 1 || has_non_viewer_data(&entries, viewer_uid.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for BillingCycleUsageSectionView {
|
||||
type Action = BillingCycleUsageAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
BillingCycleUsageAction::SelectPeriod(period_end) => {
|
||||
self.selected_period_end = *period_end;
|
||||
self.period_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
BillingCycleUsageAction::TogglePeriodMenu => {
|
||||
self.period_menu_open = !self.period_menu_open;
|
||||
if self.period_menu_open {
|
||||
self.refresh_period_menu_items(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
BillingCycleUsageAction::ChangeSourceFilter(filter) => {
|
||||
self.source_filter = *filter;
|
||||
ctx.notify();
|
||||
}
|
||||
BillingCycleUsageAction::OpenUpgrade => {
|
||||
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
ctx.open_url(&UserWorkspaces::upgrade_link_for_team(team_uid));
|
||||
}
|
||||
}
|
||||
BillingCycleUsageAction::OpenAdminPanel => {
|
||||
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
AdminActions::open_admin_panel(team_uid, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingCycleUsageSectionView {
|
||||
fn refresh_period_menu_items(&self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(data) = workspace.billing_cycle_usage.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let items: Vec<MenuItem<BillingCycleUsageAction>> = data
|
||||
.summaries
|
||||
.iter()
|
||||
.map(|summary| {
|
||||
let label = format_period_range(summary.period_start, summary.period_end);
|
||||
MenuItem::Item(MenuItemFields::new(label).with_on_select_action(
|
||||
BillingCycleUsageAction::SelectPeriod(Some(summary.period_end)),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.period_menu
|
||||
.update(ctx, |menu: &mut Menu<BillingCycleUsageAction>, ctx| {
|
||||
menu.set_items(items, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl View for BillingCycleUsageSectionView {
|
||||
fn ui_name() -> &'static str {
|
||||
"BillingCycleUsageSection"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let workspace = UserWorkspaces::as_ref(app).current_workspace().cloned();
|
||||
match workspace.as_ref() {
|
||||
Some(w) if self.shows_team_section(w, app) => {
|
||||
self.render_team_usage(w, appearance, app)
|
||||
}
|
||||
Some(w) => self.render_own_usage_with_workspace(w, appearance, app),
|
||||
None => self.render_own_usage_solo(appearance, app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingCycleUsageSectionView {
|
||||
fn render_team_usage(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let is_admin = Self::viewer_is_admin(app);
|
||||
let visibility = workspace.resolve_usage_visibility(is_admin);
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(self.render_header(Some(workspace), &visibility, appearance, app));
|
||||
|
||||
let entries = filter_legacy_buckets(
|
||||
self.current_summary(workspace)
|
||||
.map(|summary| summary.entries.as_slice())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
let is_source_filter_shown = visibility.granularity
|
||||
== UsageVisibilityGranularity::FullBreakdown
|
||||
&& has_cloud_usage(&entries);
|
||||
let source_filter = if is_source_filter_shown {
|
||||
self.source_filter
|
||||
} else {
|
||||
SourceFilter::All
|
||||
};
|
||||
|
||||
column.add_child(
|
||||
Container::new(render_team_totals_block(
|
||||
&entries,
|
||||
&visibility,
|
||||
&self.row_mouse_states,
|
||||
appearance,
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_admin {
|
||||
if let Some(banner) = self.render_visibility_cta_banner(workspace, appearance) {
|
||||
column.add_child(Container::new(banner).with_margin_top(16.).finish());
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(
|
||||
Container::new(render_rows(
|
||||
workspace,
|
||||
&entries,
|
||||
&visibility,
|
||||
source_filter,
|
||||
&self.row_mouse_states,
|
||||
appearance,
|
||||
app,
|
||||
std::sync::Arc::new(|filter, ctx| {
|
||||
ctx.dispatch_typed_action(BillingCycleUsageAction::ChangeSourceFilter(filter));
|
||||
}),
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
column.finish()
|
||||
}
|
||||
|
||||
fn render_own_usage_with_workspace(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let visibility = workspace.resolve_usage_visibility(Self::viewer_is_admin(app));
|
||||
let entries = filter_legacy_buckets(
|
||||
self.current_summary(workspace)
|
||||
.map(|s| s.entries.as_slice())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(self.render_header(Some(workspace), &visibility, appearance, app));
|
||||
column.add_child(
|
||||
Container::new(render_own_usage_with_workspace_row(
|
||||
&entries,
|
||||
&self.row_mouse_states,
|
||||
appearance,
|
||||
app,
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
column.finish()
|
||||
}
|
||||
|
||||
// Here when you're not on a team, there's no workspace to pull billing_cycle_usage data from.
|
||||
// So we "fake" a row and source data from the AIRequestUsageModel instead
|
||||
fn render_own_usage_solo(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(self.render_header(None, &UsageVisibility::default(), appearance, app));
|
||||
column.add_child(
|
||||
Container::new(render_own_usage_solo_row(
|
||||
&self.row_mouse_states,
|
||||
appearance,
|
||||
app,
|
||||
))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingCycleUsageSectionView {
|
||||
fn render_header(
|
||||
&self,
|
||||
workspace: Option<&Workspace>,
|
||||
visibility: &UsageVisibility,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
row.add_child(
|
||||
Text::new_inline("Usage", appearance.ui_font_family(), HEADER_FONT_SIZE)
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut right_side = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
|
||||
// Collapse to a static label when there's effectively one period to
|
||||
// pick from: either the tier policy doesn't expose history at all, or
|
||||
// the server returned a single canonical cycle.
|
||||
if let Some(workspace) = workspace {
|
||||
let summary_count = workspace
|
||||
.billing_cycle_usage
|
||||
.as_ref()
|
||||
.map(|d| d.summaries.len())
|
||||
.unwrap_or(0);
|
||||
let use_selector =
|
||||
visibility.max_prior_cycles != MaxPriorCycles::None && summary_count > 1;
|
||||
let period_element = if use_selector {
|
||||
self.render_period_selector(workspace, appearance)
|
||||
} else {
|
||||
self.render_period_range_static(workspace, appearance)
|
||||
};
|
||||
right_side.add_child(period_element);
|
||||
}
|
||||
|
||||
row.add_child(right_side.finish());
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(row.finish());
|
||||
|
||||
let resets_text = self.render_resets_label(appearance, app);
|
||||
let legend = workspace.and_then(|workspace| self.render_legend(workspace, appearance));
|
||||
if resets_text.is_some() || legend.is_some() {
|
||||
let mut secondary_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
secondary_row.add_child(resets_text.unwrap_or_else(|| Empty::new().finish()));
|
||||
secondary_row.add_child(legend.unwrap_or_else(|| Empty::new().finish()));
|
||||
column.add_child(
|
||||
Container::new(secondary_row.finish())
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(column.finish()).finish()
|
||||
}
|
||||
|
||||
/// "Resets May 27, 11:24 PM EDT"
|
||||
fn render_resets_label(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
if self.selected_period_end.is_some() {
|
||||
return None;
|
||||
}
|
||||
let theme = appearance.theme();
|
||||
let reset_str = AIRequestUsageModel::as_ref(app)
|
||||
.next_refresh_time_local()
|
||||
.format("Resets %b %d, %-I:%M %p")
|
||||
.to_string();
|
||||
Some(
|
||||
Text::new_inline(
|
||||
reset_str,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
// "May 13 - Jun 13, 2026"
|
||||
fn render_period_range_static(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let label = self
|
||||
.current_summary(workspace)
|
||||
.map(|s| format_period_range(s.period_start, s.period_end))
|
||||
.or_else(|| {
|
||||
workspace.billing_cycle_usage.as_ref().map(|data| {
|
||||
format_period_range(data.current_period_start, data.current_period_end)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Text::new_inline(
|
||||
label,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_period_selector(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let bg = theme.background();
|
||||
let label = self
|
||||
.current_summary(workspace)
|
||||
.map(|s| format_period_range(s.period_start, s.period_end))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mouse_state = self.period_selector_mouse_state.clone();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
let main_text = theme.sub_text_color(bg);
|
||||
|
||||
let button = Hoverable::new(mouse_state, move |_| {
|
||||
let mut inner = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
inner.add_child(
|
||||
Text::new_inline(label.clone(), font_family, font_size)
|
||||
.with_color(main_text.into())
|
||||
.finish(),
|
||||
);
|
||||
inner.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::ChevronDown.to_warpui_icon(main_text).finish())
|
||||
.with_width(12.)
|
||||
.with_height(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
);
|
||||
inner.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(BillingCycleUsageAction::TogglePeriodMenu);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(button);
|
||||
if self.period_menu_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.period_menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
}
|
||||
|
||||
fn render_legend(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let summary = self.current_summary(workspace)?;
|
||||
// Only list buckets that actually contribute to the stacked bars: drop
|
||||
// legacy buckets and cost types with no usage, so the legend never
|
||||
// shows a bucket (e.g. "Base") that has zero credits in the data.
|
||||
let present_buckets = legend_cost_types(&summary.entries);
|
||||
if present_buckets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
for (idx, bucket) in present_buckets.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
row.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
row.add_child(self.render_legend_entry(bucket.clone(), appearance));
|
||||
}
|
||||
Some(row.finish())
|
||||
}
|
||||
|
||||
fn render_legend_entry(
|
||||
&self,
|
||||
cost_type: AiCreditsUsageAndCostType,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let (color, label) = legend_style_for(cost_type.clone());
|
||||
let theme = appearance.theme();
|
||||
let entry = {
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
LEGEND_DOT_SIZE / 2.,
|
||||
)))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(LEGEND_DOT_SIZE)
|
||||
.with_width(LEGEND_DOT_SIZE)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
label,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
row.finish()
|
||||
};
|
||||
|
||||
// The Aggregate bucket replaces per-cost-type detail with a single
|
||||
// "Combined" row, which isn't self-explanatory; surface a small
|
||||
// hover tooltip clarifying what it includes.
|
||||
if !matches!(cost_type, AiCreditsUsageAndCostType::Aggregate) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
let mouse_state = self.aggregate_legend_mouse_state.clone();
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(entry);
|
||||
if state.is_hovered() {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_aggregate_legend_tooltip(appearance),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 6.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ChildAnchor::TopMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the CTA banner that sits between the team-totals block and
|
||||
/// the per-member rows. The copy and action vary by visibility tier:
|
||||
/// non-FullBreakdown admins see an upgrade nudge; FullBreakdown admins
|
||||
/// see a pointer to the admin panel where per-user spend limits actually
|
||||
/// get configured.
|
||||
fn render_visibility_cta_banner(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let admin_granularity = workspace
|
||||
.billing_metadata
|
||||
.tier
|
||||
.usage_visibility_policy?
|
||||
.admin_granularity;
|
||||
if admin_granularity == UsageVisibilityGranularity::FullBreakdown
|
||||
&& !workspace.billing_metadata.is_enterprise_plan()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (link_text, trailing_copy, action, leading_icon) =
|
||||
visibility_cta_for(admin_granularity)?;
|
||||
|
||||
// Only show when there are teammates -- a single-member workspace
|
||||
// doesn't benefit from any of the team-level visibility CTAs.
|
||||
if workspace.members.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let theme = appearance.theme();
|
||||
let sub_text = theme.sub_text_color(theme.background());
|
||||
let body = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::hyperlink_action(link_text, action),
|
||||
FormattedTextFragment::plain_text(format!(" {trailing_copy}")),
|
||||
])]),
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
sub_text.into(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(|lens, event, ctx| match lens {
|
||||
HyperlinkLens::Url(u) => ctx.open_url(u),
|
||||
HyperlinkLens::Action(a) => {
|
||||
if let Some(act) = a.as_any().downcast_ref::<BillingCycleUsageAction>() {
|
||||
event.dispatch_typed_action(act.clone());
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
let icon = ConstrainedBox::new(leading_icon.to_warpui_icon(sub_text).finish())
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Container::new(icon).with_margin_right(8.).finish())
|
||||
.with_child(body)
|
||||
.finish();
|
||||
|
||||
Some(
|
||||
Container::new(row)
|
||||
.with_background_color(theme.surface_1().into_solid())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_uniform_padding(12.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the (link text, trailing copy, action, icon) tuple for the
|
||||
/// visibility CTA banner, or `None` to suppress the banner entirely.
|
||||
fn visibility_cta_for(
|
||||
granularity: UsageVisibilityGranularity,
|
||||
) -> Option<(&'static str, &'static str, BillingCycleUsageAction, Icon)> {
|
||||
match granularity {
|
||||
UsageVisibilityGranularity::OwnOnly => Some((
|
||||
"Upgrade to Build",
|
||||
"to see team-level credit usage.",
|
||||
BillingCycleUsageAction::OpenUpgrade,
|
||||
Icon::ArrowCircleBrokenUp,
|
||||
)),
|
||||
UsageVisibilityGranularity::TeamAggregate => Some((
|
||||
"Upgrade to Business",
|
||||
"to see per-user credit attribution.",
|
||||
BillingCycleUsageAction::OpenUpgrade,
|
||||
Icon::ArrowCircleBrokenUp,
|
||||
)),
|
||||
UsageVisibilityGranularity::PerUserTotals => Some((
|
||||
"Upgrade to Enterprise",
|
||||
"to see fine-grained credit attribution and set per-user spend limits.",
|
||||
BillingCycleUsageAction::OpenUpgrade,
|
||||
Icon::ArrowCircleBrokenUp,
|
||||
)),
|
||||
// FullBreakdown viewers already have full visibility; nudge them to
|
||||
// the admin panel where per-user spend limits actually get configured.
|
||||
UsageVisibilityGranularity::FullBreakdown => Some((
|
||||
"Open the admin panel",
|
||||
"to set per-user spend limits.",
|
||||
BillingCycleUsageAction::OpenAdminPanel,
|
||||
Icon::Users,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn legend_style_for(cost_type: AiCreditsUsageAndCostType) -> (ColorU, &'static str) {
|
||||
match cost_type {
|
||||
AiCreditsUsageAndCostType::BaseLimit => (BASE_CREDITS_DOT_COLOR, "Base"),
|
||||
AiCreditsUsageAndCostType::BonusGrant => (BONUS_CREDITS_DOT_COLOR, "Add-ons"),
|
||||
AiCreditsUsageAndCostType::Payg => (PAYG_CREDITS_DOT_COLOR, "Pay-as-you-go"),
|
||||
AiCreditsUsageAndCostType::AmbientBonusGrant => (AMBIENT_CREDITS_DOT_COLOR, "Cloud-only"),
|
||||
AiCreditsUsageAndCostType::Aggregate => (AGGREGATE_CREDITS_DOT_COLOR, "Combined"),
|
||||
AiCreditsUsageAndCostType::Other(_) => (BASE_CREDITS_DOT_COLOR, ""),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_aggregate_legend_tooltip(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let text = Text::new_inline(
|
||||
"Other team members' usage across add-on, pay-as-you-go, and cloud-only credits."
|
||||
.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish();
|
||||
Container::new(text)
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_color(theme.outline().into_solid()))
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_drop_shadow(
|
||||
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 48))
|
||||
.with_offset(vec2f(0., 4.)),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn format_period_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
|
||||
let start = start.with_timezone(&Local);
|
||||
let end = end.with_timezone(&Local);
|
||||
if start.year() == end.year() {
|
||||
format!("{} - {}", start.format("%b %d"), end.format("%b %d, %Y"))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
start.format("%b %d, %Y"),
|
||||
end.format("%b %d, %Y")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
|
||||
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
|
||||
Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::Element;
|
||||
|
||||
use crate::settings_view::billing_and_usage::billing_cycle_usage_common::{
|
||||
aggregate_segments, cost_type_color, format_cost_cents, format_credits,
|
||||
render_breakdown_tooltip, render_section_subheader, BarSegment, BillingUsageMouseStates,
|
||||
ROW_BORDER_RADIUS, ROW_BORDER_WIDTH, TOOLTIP_GAP,
|
||||
};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageBucket, AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility,
|
||||
UsageVisibilityGranularity,
|
||||
};
|
||||
|
||||
fn collapse_segments_to_cost_type(segments: Vec<BarSegment>) -> Vec<BarSegment> {
|
||||
let mut out: Vec<BarSegment> = Vec::new();
|
||||
for seg in segments {
|
||||
if let Some(existing) = out.iter_mut().find(|s| s.cost_type == seg.cost_type) {
|
||||
existing.credits += seg.credits;
|
||||
existing.cost_cents += seg.cost_cents;
|
||||
} else {
|
||||
out.push(BarSegment {
|
||||
cost_type: seg.cost_type,
|
||||
usage_bucket: AiCreditsUsageBucket::Aggregate,
|
||||
credits: seg.credits,
|
||||
cost_cents: seg.cost_cents,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pill-shaped bar at the bottom of each team-totals card.
|
||||
const CARD_BAR_HEIGHT: f32 = 8.;
|
||||
const CARD_BAR_RADIUS: f32 = CARD_BAR_HEIGHT / 2.;
|
||||
|
||||
/// Summary backing a single team-totals card (Overall / Local / Cloud).
|
||||
#[derive(Debug)]
|
||||
pub struct TeamTotalCardSummary {
|
||||
pub title: &'static str,
|
||||
pub card_key: &'static str,
|
||||
pub segments: Vec<BarSegment>,
|
||||
pub total_credits: i64,
|
||||
pub total_cost_cents: i64,
|
||||
pub limit_cents: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn build_team_total_card_summaries(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
visibility: &UsageVisibility,
|
||||
) -> Vec<TeamTotalCardSummary> {
|
||||
let (overall_segments, overall_credits, overall_cost) = aggregate_segments(entries.iter());
|
||||
let mut summaries = vec![TeamTotalCardSummary {
|
||||
title: "Overall usage",
|
||||
card_key: "__card_overall__",
|
||||
segments: overall_segments,
|
||||
total_credits: overall_credits,
|
||||
total_cost_cents: overall_cost,
|
||||
limit_cents: None,
|
||||
}];
|
||||
|
||||
let shows_per_source = matches!(
|
||||
visibility.granularity,
|
||||
UsageVisibilityGranularity::FullBreakdown
|
||||
);
|
||||
if shows_per_source {
|
||||
let (local_segments, local_credits, local_cost) = aggregate_segments(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| e.usage_source == AiCreditsUsageSource::Local),
|
||||
);
|
||||
let (cloud_segments, cloud_credits, cloud_cost) = aggregate_segments(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| e.usage_source == AiCreditsUsageSource::Cloud),
|
||||
);
|
||||
summaries.push(TeamTotalCardSummary {
|
||||
title: "Local agent usage",
|
||||
card_key: "__card_local__",
|
||||
segments: local_segments,
|
||||
total_credits: local_credits,
|
||||
total_cost_cents: local_cost,
|
||||
limit_cents: None,
|
||||
});
|
||||
summaries.push(TeamTotalCardSummary {
|
||||
title: "Cloud agent usage",
|
||||
card_key: "__card_cloud__",
|
||||
segments: cloud_segments,
|
||||
total_credits: cloud_credits,
|
||||
total_cost_cents: cloud_cost,
|
||||
limit_cents: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Visibility tiers below FullBreakdown don't expose per-bucket detail,
|
||||
// so collapse bucket-dimensioned segments into single per-cost-type lines.
|
||||
// Otherwise we get a "Base (AI)" row + a separate bare "Base" row in the team aggregate card.
|
||||
if !matches!(
|
||||
visibility.granularity,
|
||||
UsageVisibilityGranularity::FullBreakdown
|
||||
) {
|
||||
for summary in &mut summaries {
|
||||
summary.segments =
|
||||
collapse_segments_to_cost_type(std::mem::take(&mut summary.segments));
|
||||
}
|
||||
}
|
||||
|
||||
summaries
|
||||
}
|
||||
|
||||
fn render_card_pill_bar(
|
||||
segments: &[BarSegment],
|
||||
total_credits: i64,
|
||||
total_cost_cents: i64,
|
||||
limit_cents: Option<i64>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let track_bg = theme.surface_overlay_1();
|
||||
let corner = Radius::Pixels(CARD_BAR_RADIUS);
|
||||
|
||||
if total_credits == 0 || segments.is_empty() {
|
||||
return ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background(track_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(corner))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(CARD_BAR_HEIGHT)
|
||||
.finish();
|
||||
}
|
||||
|
||||
let fill_ratio = match limit_cents {
|
||||
Some(limit) if limit > 0 => (total_cost_cents as f32 / limit as f32).clamp(0.0, 1.0),
|
||||
_ => 1.0,
|
||||
};
|
||||
let unfill_ratio = 1.0 - fill_ratio;
|
||||
let has_unfill = unfill_ratio > 0.0;
|
||||
let last_segment_idx = segments.len() - 1;
|
||||
|
||||
let mut filled = Flex::row();
|
||||
for (idx, seg) in segments.iter().enumerate() {
|
||||
let weight = seg.credits as f32 / total_credits as f32;
|
||||
if weight <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let is_first = idx == 0;
|
||||
let is_last_visible = idx == last_segment_idx && !has_unfill;
|
||||
let segment_corner = match (is_first, is_last_visible) {
|
||||
(true, true) => CornerRadius::with_all(corner),
|
||||
(true, false) => CornerRadius::with_left(corner),
|
||||
(false, true) => CornerRadius::with_right(corner),
|
||||
(false, false) => CornerRadius::default(),
|
||||
};
|
||||
filled.add_child(
|
||||
Expanded::new(
|
||||
weight,
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background_color(cost_type_color(&seg.cost_type))
|
||||
.with_corner_radius(segment_corner)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut bar = Flex::row();
|
||||
bar.add_child(Expanded::new(fill_ratio, filled.finish()).finish());
|
||||
if has_unfill {
|
||||
bar.add_child(
|
||||
Expanded::new(
|
||||
unfill_ratio,
|
||||
Container::new(Empty::new().finish())
|
||||
.with_background(track_bg)
|
||||
.with_corner_radius(CornerRadius::with_right(corner))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox::new(bar.finish())
|
||||
.with_height(CARD_BAR_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Card body for one team-totals slice. Layout (top to bottom):
|
||||
/// [title]
|
||||
/// [$X.XX] [Limit: $Y.YY] (limit optional)
|
||||
/// [(N credits)]
|
||||
/// [pill stacked bar]
|
||||
fn build_team_total_card(
|
||||
summary: &TeamTotalCardSummary,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let card_bg = theme.background().into_solid();
|
||||
let main = blended_colors::text_main(theme, card_bg);
|
||||
let sub = blended_colors::text_sub(theme, card_bg);
|
||||
|
||||
let title_text = Text::new_inline(summary.title.to_string(), appearance.ui_font_family(), 13.)
|
||||
.with_color(sub)
|
||||
.with_style(Properties::default().weight(Weight::Medium))
|
||||
.finish();
|
||||
|
||||
let cost_text = Text::new_inline(
|
||||
format_cost_cents(summary.total_cost_cents),
|
||||
appearance.ui_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_color(main)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
let credits_text = Text::new_inline(
|
||||
format!("({} credits)", format_credits(summary.total_credits)),
|
||||
appearance.ui_font_family(),
|
||||
13.,
|
||||
)
|
||||
.with_color(sub)
|
||||
.finish();
|
||||
|
||||
let totals_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(cost_text)
|
||||
.with_child(Container::new(credits_text).with_margin_top(2.).finish())
|
||||
.finish();
|
||||
|
||||
let totals_row: Box<dyn Element> = match summary.limit_cents {
|
||||
Some(limit) => {
|
||||
let limit_text = Text::new_inline(
|
||||
format!("Limit: {}", format_cost_cents(limit)),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(sub)
|
||||
.finish();
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., totals_col).finish())
|
||||
.with_child(Container::new(limit_text).with_margin_left(16.).finish())
|
||||
.finish()
|
||||
}
|
||||
None => totals_col,
|
||||
};
|
||||
|
||||
let bar = render_card_pill_bar(
|
||||
&summary.segments,
|
||||
summary.total_credits,
|
||||
summary.total_cost_cents,
|
||||
summary.limit_cents,
|
||||
appearance,
|
||||
);
|
||||
|
||||
let body = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(12.)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(6.)
|
||||
.with_child(title_text)
|
||||
.with_child(totals_row)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(bar)
|
||||
.finish();
|
||||
|
||||
Container::new(body)
|
||||
.with_background_color(card_bg)
|
||||
.with_border(Border::all(ROW_BORDER_WIDTH).with_border_color(theme.outline().into_solid()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_BORDER_RADIUS)))
|
||||
.with_uniform_padding(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_team_total_card(
|
||||
summary: &TeamTotalCardSummary,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
if summary.segments.is_empty() {
|
||||
return build_team_total_card(summary, appearance);
|
||||
}
|
||||
|
||||
Hoverable::new(tooltip_mouse_state, move |state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(build_team_total_card(summary, appearance));
|
||||
|
||||
if state.is_hovered() {
|
||||
stack.add_positioned_overlay_child(
|
||||
render_breakdown_tooltip(
|
||||
&summary.segments,
|
||||
summary.total_credits,
|
||||
summary.total_cost_cents,
|
||||
appearance,
|
||||
),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -TOOLTIP_GAP),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Horizontal row of team-totals cards (Overall + Local + Cloud).
|
||||
fn render_team_totals_section(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
visibility: &UsageVisibility,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let summaries = build_team_total_card_summaries(entries, visibility);
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_spacing(12.);
|
||||
for summary in &summaries {
|
||||
let tooltip_state = mouse_states.tooltip_mouse_state(summary.card_key);
|
||||
row.add_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
render_team_total_card(summary, tooltip_state, appearance),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
row.finish()
|
||||
}
|
||||
|
||||
/// "Team" subheader + cards
|
||||
pub fn render_team_totals_block(
|
||||
entries: &[BillingCycleUsageEntry],
|
||||
visibility: &UsageVisibility,
|
||||
mouse_states: &BillingUsageMouseStates,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(
|
||||
Container::new(render_section_subheader("Team", appearance))
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
column.add_child(render_team_totals_section(
|
||||
entries,
|
||||
visibility,
|
||||
mouse_states,
|
||||
appearance,
|
||||
));
|
||||
column.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "billing_cycle_usage_team_totals_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,95 +0,0 @@
|
||||
use super::{build_team_total_card_summaries, TeamTotalCardSummary};
|
||||
use crate::workspaces::workspace::{
|
||||
AiCreditsUsageAndCostSubjectType, AiCreditsUsageAndCostType, AiCreditsUsageBucket,
|
||||
AiCreditsUsageSource, BillingCycleUsageEntry, UsageVisibility, UsageVisibilityGranularity,
|
||||
};
|
||||
|
||||
fn entry(
|
||||
usage_source: AiCreditsUsageSource,
|
||||
credits_used: i32,
|
||||
cost_cents: i32,
|
||||
) -> BillingCycleUsageEntry {
|
||||
BillingCycleUsageEntry {
|
||||
subject_type: AiCreditsUsageAndCostSubjectType::User,
|
||||
subject_uid: Some("u".to_string()),
|
||||
subject_display_name: None,
|
||||
cost_type: AiCreditsUsageAndCostType::BaseLimit,
|
||||
usage_bucket: AiCreditsUsageBucket::Ai,
|
||||
usage_source,
|
||||
credits_used,
|
||||
cost_cents,
|
||||
}
|
||||
}
|
||||
|
||||
fn visibility(granularity: UsageVisibilityGranularity) -> UsageVisibility {
|
||||
UsageVisibility {
|
||||
granularity,
|
||||
max_prior_cycles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_two_per_source() -> Vec<BillingCycleUsageEntry> {
|
||||
vec![
|
||||
entry(AiCreditsUsageSource::Local, 30, 10),
|
||||
entry(AiCreditsUsageSource::Cloud, 70, 25),
|
||||
]
|
||||
}
|
||||
|
||||
fn titles(summaries: &[TeamTotalCardSummary]) -> Vec<&'static str> {
|
||||
summaries.iter().map(|s| s.title).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_aggregate_visibility_yields_overall_card_only() {
|
||||
// Server collapses teammates' usage into an `Aggregate`-source row under
|
||||
// TeamAggregate, so the Local/Cloud split can't be honestly attributed
|
||||
// — only the Overall card is meaningful.
|
||||
let summaries = build_team_total_card_summaries(
|
||||
&entries_two_per_source(),
|
||||
&visibility(UsageVisibilityGranularity::TeamAggregate),
|
||||
);
|
||||
assert_eq!(titles(&summaries), vec!["Overall usage"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_only_visibility_yields_overall_card_only() {
|
||||
// OwnOnly viewers don't normally render the team-totals block at all,
|
||||
// but the builder should still degrade gracefully to a single card.
|
||||
let summaries = build_team_total_card_summaries(
|
||||
&entries_two_per_source(),
|
||||
&visibility(UsageVisibilityGranularity::OwnOnly),
|
||||
);
|
||||
assert_eq!(titles(&summaries), vec!["Overall usage"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_user_totals_visibility_yields_overall_card_only() {
|
||||
let summaries = build_team_total_card_summaries(
|
||||
&entries_two_per_source(),
|
||||
&visibility(UsageVisibilityGranularity::PerUserTotals),
|
||||
);
|
||||
assert_eq!(titles(&summaries), vec!["Overall usage"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_breakdown_visibility_returns_three_cards_with_partitioned_sums() {
|
||||
let summaries = build_team_total_card_summaries(
|
||||
&entries_two_per_source(),
|
||||
&visibility(UsageVisibilityGranularity::FullBreakdown),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
titles(&summaries),
|
||||
vec!["Overall usage", "Local agent usage", "Cloud agent usage"]
|
||||
);
|
||||
|
||||
// Overall = Local + Cloud; Local card = only Local entries; Cloud card =
|
||||
// only Cloud entries. Distinct credits/cost per source catch any swapped
|
||||
// filter.
|
||||
assert_eq!(summaries[0].total_credits, 30 + 70);
|
||||
assert_eq!(summaries[0].total_cost_cents, 10 + 25);
|
||||
assert_eq!(summaries[1].total_credits, 30);
|
||||
assert_eq!(summaries[1].total_cost_cents, 10);
|
||||
assert_eq!(summaries[2].total_credits, 70);
|
||||
assert_eq!(summaries[2].total_cost_cents, 25);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod billing_cycle_usage_common;
|
||||
pub mod billing_cycle_usage_rows;
|
||||
pub mod billing_cycle_usage_section;
|
||||
pub mod billing_cycle_usage_team_totals;
|
||||
pub mod overage_limit_modal;
|
||||
pub mod usage_history_entry;
|
||||
pub mod usage_history_model;
|
||||
@@ -1,352 +0,0 @@
|
||||
use galaxyui::elements::{
|
||||
Align, Border, ChildView, Clipped, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
|
||||
MouseStateHandle, Padding, ParentElement, Radius, Text,
|
||||
};
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
|
||||
use crate::Appearance;
|
||||
|
||||
const MAXIMUM_SPENDING_LIMIT_CENTS: u32 = 999999999;
|
||||
|
||||
pub struct SpendingLimitModal {
|
||||
amount_editor: ViewHandle<EditorView>,
|
||||
cancel_button_mouse_state: MouseStateHandle,
|
||||
update_button_mouse_state: MouseStateHandle,
|
||||
input_error_state: Option<SpendingLimitModalInputErrorState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SpendingLimitModalEvent {
|
||||
Close,
|
||||
Update { amount_cents: u32 },
|
||||
}
|
||||
|
||||
impl Entity for SpendingLimitModal {
|
||||
type Event = SpendingLimitModalEvent;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SpendingLimitModalAction {
|
||||
Close,
|
||||
Update,
|
||||
}
|
||||
|
||||
pub enum SpendingLimitModalInputErrorState {
|
||||
InvalidNumberFormat,
|
||||
NumberOutOfRange,
|
||||
}
|
||||
|
||||
impl SpendingLimitModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let font_family = Appearance::as_ref(ctx).ui_font_family();
|
||||
let amount_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let options = SingleLineEditorOptions {
|
||||
text: TextOptions {
|
||||
font_family_override: Some(font_family),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text("50.00", ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&amount_editor, |me, _, event, ctx| {
|
||||
me.handle_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
amount_editor,
|
||||
cancel_button_mouse_state: MouseStateHandle::default(),
|
||||
update_button_mouse_state: MouseStateHandle::default(),
|
||||
input_error_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_amount(&self, app: &AppContext) -> Option<u32> {
|
||||
let text = self.amount_editor.as_ref(app).buffer_text(app);
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cleaned = text.strip_prefix('$').unwrap_or(text);
|
||||
|
||||
if let Ok(dollars) = cleaned.parse::<f64>() {
|
||||
Some((dollars * 100.0).round() as u32)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_input(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let text = self.amount_editor.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
if !self.is_valid_us_currency_format(&text) {
|
||||
self.input_error_state = Some(SpendingLimitModalInputErrorState::InvalidNumberFormat);
|
||||
} else if self
|
||||
.parse_amount(ctx)
|
||||
.is_some_and(|cents| !self.is_valid_number_range(cents))
|
||||
{
|
||||
self.input_error_state = Some(SpendingLimitModalInputErrorState::NumberOutOfRange);
|
||||
} else {
|
||||
self.input_error_state = None;
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn is_valid_number_range(&self, amount_cents: u32) -> bool {
|
||||
if amount_cents < 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if amount_cents > MAXIMUM_SPENDING_LIMIT_CENTS {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_valid_us_currency_format(&self, text: &str) -> bool {
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cleaned = text.strip_prefix('$').unwrap_or(text);
|
||||
|
||||
let decimal_count = cleaned.chars().filter(|&c| c == '.').count();
|
||||
if decimal_count > 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !cleaned.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(decimal_pos) = cleaned.find('.') {
|
||||
let after_decimal = &cleaned[decimal_pos + 1..];
|
||||
if after_decimal.len() > 2 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn update_amount_editor(&self, cents: u32, ctx: &mut ViewContext<Self>) {
|
||||
let placeholder_text = format!("{:.2}", cents as f64 / 100.0);
|
||||
self.amount_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_placeholder_text(&placeholder_text, ctx);
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn error_text(&self) -> Option<String> {
|
||||
match self.input_error_state {
|
||||
Some(SpendingLimitModalInputErrorState::InvalidNumberFormat) => {
|
||||
Some("Please enter a valid currency amount".to_string())
|
||||
}
|
||||
Some(SpendingLimitModalInputErrorState::NumberOutOfRange) => {
|
||||
Some("Please enter a price between $0.01 and $10,000,000".to_string())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_input(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus(&self.amount_editor);
|
||||
}
|
||||
|
||||
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Enter => {
|
||||
if self.input_error_state.is_none() {
|
||||
if let Some(amount_cents) = self.parse_amount(ctx) {
|
||||
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorEvent::Escape => {
|
||||
ctx.emit(SpendingLimitModalEvent::Close);
|
||||
}
|
||||
EditorEvent::Edited(_) => {
|
||||
self.validate_input(ctx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for SpendingLimitModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"SpendingLimitModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let description_text = Text::new(
|
||||
"Warp will prevent use of premium models when this dollar limit is reached. Resets on a monthly basis.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish();
|
||||
|
||||
let additional_note_text = Text::new(
|
||||
"Note that AI credits made near your chosen limit may exceed it by a few dollars.",
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish();
|
||||
|
||||
let description_section = Flex::column()
|
||||
.with_child(
|
||||
Container::new(description_text)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(additional_note_text)
|
||||
.finish();
|
||||
|
||||
let input_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new("$", appearance.ui_font_family(), appearance.ui_font_size())
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Container::new(
|
||||
Clipped::new(ChildView::new(&self.amount_editor).finish()).finish(),
|
||||
)
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let border_color = if self.input_error_state.is_some() {
|
||||
theme.ui_error_color().into()
|
||||
} else {
|
||||
theme.outline()
|
||||
};
|
||||
|
||||
let input_container = Container::new(input_row)
|
||||
.with_padding(Padding::uniform(8.).with_left(16.).with_right(16.))
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut update_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.update_button_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Update".to_string())
|
||||
.with_style(button_style);
|
||||
|
||||
if self.input_error_state.is_some() {
|
||||
update_button = update_button.disabled();
|
||||
}
|
||||
|
||||
let buttons_row = Flex::row()
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.cancel_button_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Cancel".to_string())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SpendingLimitModalAction::Close);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
update_button
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SpendingLimitModalAction::Update);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut main_column = Flex::column()
|
||||
.with_child(
|
||||
Container::new(description_section)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(input_container)
|
||||
.with_margin_bottom(if self.input_error_state.is_some() {
|
||||
8.
|
||||
} else {
|
||||
24.
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(error_text) = self.error_text() {
|
||||
let error_text = Text::new(error_text, appearance.ui_font_family(), 12.)
|
||||
.with_color(theme.ui_error_color())
|
||||
.finish();
|
||||
|
||||
main_column =
|
||||
main_column.with_child(Container::new(error_text).with_margin_bottom(24.).finish());
|
||||
}
|
||||
|
||||
main_column
|
||||
.with_child(Align::new(buttons_row).right().finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SpendingLimitModal {
|
||||
type Action = SpendingLimitModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SpendingLimitModalAction::Close => {
|
||||
self.amount_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
ctx.emit(SpendingLimitModalEvent::Close);
|
||||
}
|
||||
SpendingLimitModalAction::Update => {
|
||||
if let Some(amount_cents) = self.parse_amount(ctx) {
|
||||
ctx.emit(SpendingLimitModalEvent::Update { amount_cents });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
use chrono::Local;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_graphql::queries::get_conversation_usage::ConversationUsage;
|
||||
use galaxyui::elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{AppContext, Element, View};
|
||||
|
||||
use crate::ai::blocklist::format_credits;
|
||||
use crate::ai::blocklist::usage::conversation_usage_view::{
|
||||
ConversationUsageInfo, ConversationUsageView, DisplayMode,
|
||||
};
|
||||
use crate::settings_view::billing_and_usage_page::BillingAndUsagePageAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
pub struct UsageHistoryEntry {
|
||||
// If no entry is provided, we will assume that this is a placeholder entry
|
||||
// to display in the loading UI.
|
||||
entry: Option<ConversationUsage>,
|
||||
is_expanded: bool,
|
||||
mouse_state: Option<MouseStateHandle>,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl UsageHistoryEntry {
|
||||
pub fn new(
|
||||
entry: Option<ConversationUsage>,
|
||||
is_expanded: bool,
|
||||
mouse_state: Option<MouseStateHandle>,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
entry,
|
||||
mouse_state,
|
||||
is_expanded,
|
||||
tooltip_mouse_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let mut res = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(self.render_header(appearance));
|
||||
|
||||
if let Some(entry) = &self.entry {
|
||||
if self.is_expanded {
|
||||
res = res
|
||||
.with_child(
|
||||
// Separator between header and usage component
|
||||
Container::new(Empty::new().finish())
|
||||
.with_border(
|
||||
Border::top(2.0).with_border_fill(appearance.theme().outline()),
|
||||
)
|
||||
.with_overdraw_bottom(0.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ConversationUsageView::new(
|
||||
ConversationUsageInfo::from(entry),
|
||||
DisplayMode::Settings,
|
||||
None,
|
||||
self.tooltip_mouse_state.clone(),
|
||||
)
|
||||
.render(app),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Container::new(res.finish())
|
||||
.with_border(Border::all(2.).with_border_fill(appearance.theme().surface_3()))
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let Some(entry) = &self.entry else {
|
||||
return self.render_loading_entry(appearance);
|
||||
};
|
||||
let Some(mouse_state) = &self.mouse_state else {
|
||||
// If there is a provided entry, there should always be a mouse state as well.
|
||||
log::error!("Mouse state is required to render usage history entry header");
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
let title_text = Text::new_inline(entry.title.clone(), appearance.ui_font_family(), 14.)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let formatted_time = entry
|
||||
.last_updated
|
||||
.utc()
|
||||
.with_timezone(&Local)
|
||||
.format("%-m/%-d/%y %-I:%M %p")
|
||||
.to_string();
|
||||
let time_text = Text::new_inline(formatted_time, appearance.ui_font_family(), 12.)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().surface_1(),
|
||||
))
|
||||
.finish();
|
||||
|
||||
let total_credits =
|
||||
entry.usage_metadata.credits_spent + entry.usage_metadata.platform_credits_spent;
|
||||
let credits_spent = Text::new_inline(
|
||||
format_credits(total_credits as f32),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().surface_1(),
|
||||
))
|
||||
.finish();
|
||||
|
||||
let chevron_icon = if self.is_expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
};
|
||||
let chevron = ConstrainedBox::new(
|
||||
chevron_icon
|
||||
.to_galaxyui_icon(appearance.theme().foreground())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let header_row = Container::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(title_text)
|
||||
.with_child(Container::new(time_text).with_margin_top(4.).finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(credits_spent)
|
||||
.with_child(chevron)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(12.)
|
||||
.with_corner_radius(if self.is_expanded {
|
||||
CornerRadius::with_top(Radius::Pixels(6.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(6.))
|
||||
})
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.finish();
|
||||
|
||||
let conversation_id = entry.conversation_id.clone();
|
||||
Hoverable::new(mouse_state.clone(), |_| header_row)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(BillingAndUsagePageAction::ToggleUsageEntryExpanded {
|
||||
conversation_id: conversation_id.clone(),
|
||||
});
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render a placeholder entry for the loading state
|
||||
fn render_loading_entry(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let left_side = Flex::column()
|
||||
.with_child(self.render_empty_text_placeholder(360., 16., appearance))
|
||||
.with_child(self.render_empty_text_placeholder(160., 12., appearance))
|
||||
.with_spacing(4.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(left_side)
|
||||
.with_child(self.render_empty_text_placeholder(52., 16., appearance))
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(12.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders an empty rectangle to represent loading text
|
||||
fn render_empty_text_placeholder(
|
||||
&self,
|
||||
width: f32,
|
||||
height: f32,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(Empty::new().finish())
|
||||
.with_padding_left(width)
|
||||
.with_padding_top(height)
|
||||
.with_background(appearance.theme().surface_3())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_graphql::scalars::Time;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
const PAGE_SIZE: i32 = 20;
|
||||
|
||||
pub struct UsageHistoryModel {
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
entries: Vec<galaxy_graphql::queries::get_conversation_usage::ConversationUsage>,
|
||||
is_loading: bool,
|
||||
// Whether the server indicated that there may be more entries to load.
|
||||
has_more_entries: bool,
|
||||
}
|
||||
|
||||
impl Entity for UsageHistoryModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for UsageHistoryModel {}
|
||||
|
||||
impl UsageHistoryModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
Self {
|
||||
ai_client,
|
||||
entries: Vec::new(),
|
||||
is_loading: false,
|
||||
has_more_entries: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[galaxy_graphql::queries::get_conversation_usage::ConversationUsage] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
pub fn has_more_entries(&self) -> bool {
|
||||
self.has_more_entries
|
||||
}
|
||||
|
||||
/// Fetches conversation usage over the past 30 days.
|
||||
/// If some usage has already been loaded, this fetches the same number of entries.
|
||||
/// If no usage has been loaded, this fetches PAGE_SIZE entries.
|
||||
pub fn refresh_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.is_loading || !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user has already loaded some number of entries,
|
||||
// we should load that same number of items on refresh so that the list doesn't shrink
|
||||
// every time the page is refreshed.
|
||||
let num_items_to_fetch = if self.entries.is_empty() {
|
||||
PAGE_SIZE
|
||||
} else {
|
||||
self.entries.len() as i32
|
||||
};
|
||||
|
||||
// Reset pagination state and clear any existing entries.
|
||||
self.entries.clear();
|
||||
self.has_more_entries = true;
|
||||
|
||||
self.fetch_next_page(num_items_to_fetch, None, ctx);
|
||||
}
|
||||
|
||||
/// Fetches the next page of conversation usage entries, appending them to the existing list.
|
||||
pub fn load_more_usage_history_async(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.is_loading || !self.has_more_entries {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_updated_end_timestamp: Option<Time> =
|
||||
self.entries.last().map(|entry| entry.last_updated);
|
||||
if last_updated_end_timestamp.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.fetch_next_page(PAGE_SIZE, last_updated_end_timestamp, ctx);
|
||||
}
|
||||
|
||||
/// Fetches the next page of conversation usage entries, appending them to the existing list.
|
||||
/// last_updated_end_timestamp is the timestamp of the last entry in the existing list,
|
||||
/// and is used to paginate the results and only return entries that we don't already have.
|
||||
fn fetch_next_page(
|
||||
&mut self,
|
||||
limit: i32,
|
||||
last_updated_end_timestamp: Option<Time>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// If no time stamp is provided for pagination, we can assume that this is the first page of results.
|
||||
let is_initial_load = last_updated_end_timestamp.is_none();
|
||||
let ai_client = self.ai_client.clone();
|
||||
|
||||
if is_initial_load {
|
||||
self.is_loading = true;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
ai_client
|
||||
.get_conversation_usage_history(
|
||||
Some(30),
|
||||
Some(limit),
|
||||
last_updated_end_timestamp,
|
||||
)
|
||||
.await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
me.is_loading = false;
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
let fetched_count = entries.len() as i32;
|
||||
|
||||
// If we received fewer than requested, assume there are no more entries.
|
||||
me.has_more_entries = fetched_count == limit;
|
||||
|
||||
if !is_initial_load {
|
||||
me.entries.extend(entries);
|
||||
} else {
|
||||
me.entries = entries;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
report_error!(e.context("Failed to fetch conversation usage"));
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Dispatch wrapper that routes between the legacy and v2 billing & usage
|
||||
//! pages.
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use warpui::elements::{ChildView, Container};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::billing_and_usage_page::{BillingAndUsagePageEvent, BillingAndUsagePageView};
|
||||
use super::billing_and_usage_page_v2::BillingAndUsagePageV2View;
|
||||
use super::settings_page::{
|
||||
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, HEADER_PADDING,
|
||||
};
|
||||
use super::SettingsSection;
|
||||
use crate::auth::{AuthManager, AuthStateProvider};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::Workspace;
|
||||
|
||||
pub struct BillingAndUsageDispatchView {
|
||||
page: PageType<Self>,
|
||||
v1: ViewHandle<BillingAndUsagePageView>,
|
||||
v2: ViewHandle<BillingAndUsagePageV2View>,
|
||||
}
|
||||
|
||||
impl BillingAndUsageDispatchView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let v1 = ctx.add_typed_action_view(BillingAndUsagePageView::new);
|
||||
let v2 = ctx.add_typed_action_view(BillingAndUsagePageV2View::new);
|
||||
|
||||
// Both children stay alive; only forward events from the active one
|
||||
// to avoid duplicate toasts.
|
||||
ctx.subscribe_to_view(&v1, |this, _, event, ctx| {
|
||||
if !this.use_v2(ctx) {
|
||||
ctx.emit(event.clone());
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_view(&v2, |this, _, event, ctx| {
|
||||
if this.use_v2(ctx) {
|
||||
ctx.emit(event.clone());
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let page = PageType::new_monolith(BillingAndUsageWidget, Some("Billing and Usage"), true);
|
||||
|
||||
Self { page, v1, v2 }
|
||||
}
|
||||
|
||||
fn use_v2(&self, ctx: &AppContext) -> bool {
|
||||
if !FeatureFlag::BillingAndUsagePageV2.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
Self::workspace_uses_v2(UserWorkspaces::as_ref(ctx).current_workspace())
|
||||
}
|
||||
|
||||
fn workspace_uses_v2(workspace: Option<&Workspace>) -> bool {
|
||||
workspace.is_none_or(|workspace| {
|
||||
let bm = &workspace.billing_metadata;
|
||||
bm.is_on_build_plan()
|
||||
|| bm.is_on_build_max_plan()
|
||||
|| bm.is_on_build_business_plan()
|
||||
|| bm.is_enterprise_plan()
|
||||
|| bm.is_free_plan()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if self.use_v2(app) {
|
||||
self.v2.read(app, |view, _| view.get_modal_content())
|
||||
} else {
|
||||
self.v1.read(app, |view, _| view.get_modal_content())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "billing_and_usage_dispatch_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
impl Entity for BillingAndUsageDispatchView {
|
||||
type Event = BillingAndUsagePageEvent;
|
||||
}
|
||||
|
||||
impl View for BillingAndUsageDispatchView {
|
||||
fn ui_name() -> &'static str {
|
||||
"Billing and usage"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.page.render(self, app)
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsPageMeta for BillingAndUsageDispatchView {
|
||||
fn section() -> SettingsSection {
|
||||
SettingsSection::BillingAndUsage
|
||||
}
|
||||
|
||||
fn should_render(&self, ctx: &AppContext) -> bool {
|
||||
!AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
}
|
||||
|
||||
fn on_page_selected(&mut self, allow_steal_focus: bool, ctx: &mut ViewContext<Self>) {
|
||||
if self.use_v2(ctx) {
|
||||
self.v2.update(ctx, |view, ctx| {
|
||||
view.on_page_selected(allow_steal_focus, ctx)
|
||||
});
|
||||
} else {
|
||||
self.v1.update(ctx, |view, ctx| {
|
||||
view.on_page_selected(allow_steal_focus, 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<BillingAndUsageDispatchView>> for SettingsPageViewHandle {
|
||||
fn from(view_handle: ViewHandle<BillingAndUsageDispatchView>) -> Self {
|
||||
SettingsPageViewHandle::BillingAndUsage(view_handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BillingAndUsageWidget;
|
||||
|
||||
impl SettingsWidget for BillingAndUsageWidget {
|
||||
type View = BillingAndUsageDispatchView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"plan billing a.i. ai usage limit credits balance overview"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
view: &Self::View,
|
||||
_appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let inner = if view.use_v2(app) {
|
||||
ChildView::new(&view.v2).finish()
|
||||
} else {
|
||||
ChildView::new(&view.v1).finish()
|
||||
};
|
||||
Container::new(inner)
|
||||
.with_margin_top(HEADER_PADDING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use super::*;
|
||||
use crate::workspaces::workspace::{BillingMetadata, CustomerType};
|
||||
|
||||
fn workspace_with_customer_type(customer_type: CustomerType) -> Workspace {
|
||||
Workspace {
|
||||
uid: "workspace_uid123456789".to_string().into(),
|
||||
name: "test".to_string(),
|
||||
stripe_customer_id: None,
|
||||
teams: vec![],
|
||||
billing_metadata: BillingMetadata {
|
||||
customer_type,
|
||||
..Default::default()
|
||||
},
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
billing_cycle_usage: None,
|
||||
has_billing_history: false,
|
||||
settings: Default::default(),
|
||||
invite_code: None,
|
||||
invite_link_domain_restrictions: vec![],
|
||||
pending_email_invites: vec![],
|
||||
is_eligible_for_discovery: false,
|
||||
members: vec![],
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_v2_when_user_has_no_workspace() {
|
||||
assert!(BillingAndUsageDispatchView::workspace_uses_v2(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_v2_for_free_workspace() {
|
||||
let workspace = workspace_with_customer_type(CustomerType::Free);
|
||||
|
||||
assert!(BillingAndUsageDispatchView::workspace_uses_v2(Some(
|
||||
&workspace
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_use_v2_for_legacy_paid_workspace() {
|
||||
let workspace = workspace_with_customer_type(CustomerType::Prosumer);
|
||||
|
||||
assert!(!BillingAndUsageDispatchView::workspace_uses_v2(Some(
|
||||
&workspace
|
||||
)));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
||||
use super::{sort_user_items_in_place, SortKey, SortOrder, UserSortingCriteria};
|
||||
|
||||
#[test]
|
||||
pub fn test_default_sorting_pins_current_user_first_then_display_name_asc() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("Zed".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Alice".to_string(), 5, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 15, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(&mut items, "Bob", None, SortOrder::Asc);
|
||||
|
||||
// Expected: Bob (current user) first, then Alice, then Zed (by display name asc)
|
||||
assert_eq!(items[0].display_name, "Bob");
|
||||
assert_eq!(items[1].display_name, "Alice");
|
||||
assert_eq!(items[2].display_name, "Zed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_az_sorting_pins_current_user() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("Zed".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Alice".to_string(), 5, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 15, ()),
|
||||
UserSortingCriteria::new("charlie@example.com".to_string(), 8, ()), // Using email as display name fallback
|
||||
];
|
||||
|
||||
sort_user_items_in_place(
|
||||
&mut items,
|
||||
"Bob",
|
||||
Some(SortKey::DisplayName),
|
||||
SortOrder::Asc,
|
||||
);
|
||||
|
||||
// Expected: Bob (current user) first, then Alice, charlie@ (fallback to email), Zed
|
||||
assert_eq!(items[0].display_name, "Bob");
|
||||
assert_eq!(items[1].display_name, "Alice");
|
||||
assert_eq!(items[2].display_name, "charlie@example.com"); // Email as display name fallback
|
||||
assert_eq!(items[3].display_name, "Zed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_za_sorting_pins_current_user() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("Zed".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Alice".to_string(), 5, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 15, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(
|
||||
&mut items,
|
||||
"Alice",
|
||||
Some(SortKey::DisplayName),
|
||||
SortOrder::Desc,
|
||||
);
|
||||
|
||||
// Expected: Alice (current user) first, then Zed, Bob (by name desc)
|
||||
assert_eq!(items[0].display_name, "Alice");
|
||||
assert_eq!(items[1].display_name, "Zed");
|
||||
assert_eq!(items[2].display_name, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requests_usage_desc_sorting_pins_current_user_with_display_name_tie_breaker() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("Alice".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 15, ()),
|
||||
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
|
||||
UserSortingCriteria::new("Diana".to_string(), 5, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(
|
||||
&mut items,
|
||||
"Diana",
|
||||
Some(SortKey::Requests),
|
||||
SortOrder::Desc,
|
||||
);
|
||||
|
||||
// Expected: Diana (current user) first, then Bob (15), then Alice/Charlie by name (10 tie)
|
||||
assert_eq!(items[0].display_name, "Diana");
|
||||
assert_eq!(items[1].display_name, "Bob"); // Highest usage (15)
|
||||
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
|
||||
assert_eq!(items[3].display_name, "Charlie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requests_usage_asc_sorting_pins_current_user_with_display_name_tie_breaker() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("Alice".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 15, ()),
|
||||
UserSortingCriteria::new("Charlie".to_string(), 10, ()), // Same usage as Alice
|
||||
UserSortingCriteria::new("Diana".to_string(), 5, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(&mut items, "Bob", Some(SortKey::Requests), SortOrder::Asc);
|
||||
|
||||
// Expected: Bob (current user) first, then Diana (5), then Alice/Charlie by name (10 tie)
|
||||
assert_eq!(items[0].display_name, "Bob");
|
||||
assert_eq!(items[1].display_name, "Diana"); // Lowest usage (5)
|
||||
assert_eq!(items[2].display_name, "Alice"); // Tied at 10, "Alice" < "Charlie"
|
||||
assert_eq!(items[3].display_name, "Charlie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_az_sorting_with_emails() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("zuser@example.com".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Alice".to_string(), 5, ()),
|
||||
UserSortingCriteria::new("buser@example.com".to_string(), 15, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(
|
||||
&mut items,
|
||||
"Alice",
|
||||
Some(SortKey::DisplayName),
|
||||
SortOrder::Asc,
|
||||
);
|
||||
|
||||
// Expected: Alice (current user) first, then buser@... < zuser@... (by email fallback)
|
||||
assert_eq!(items[0].display_name, "Alice");
|
||||
assert_eq!(items[1].display_name, "buser@example.com"); // Email as display name
|
||||
assert_eq!(items[2].display_name, "zuser@example.com"); // Email as display name
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_display_name_sorting() {
|
||||
let mut items = vec![
|
||||
UserSortingCriteria::new("alice".to_string(), 10, ()),
|
||||
UserSortingCriteria::new("Bob".to_string(), 5, ()),
|
||||
UserSortingCriteria::new("CHARLIE".to_string(), 8, ()),
|
||||
UserSortingCriteria::new("Diana".to_string(), 12, ()),
|
||||
];
|
||||
|
||||
sort_user_items_in_place(
|
||||
&mut items,
|
||||
"Diana",
|
||||
Some(SortKey::DisplayName),
|
||||
SortOrder::Asc,
|
||||
);
|
||||
|
||||
// Expected: Diana (current user) first, then alice, Bob, CHARLIE (case-insensitive asc)
|
||||
assert_eq!(items[0].display_name, "Diana");
|
||||
assert_eq!(items[1].display_name, "alice"); // "alice" (lowercase)
|
||||
assert_eq!(items[2].display_name, "Bob"); // "Bob"
|
||||
assert_eq!(items[3].display_name, "CHARLIE"); // "CHARLIE"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,401 +0,0 @@
|
||||
use ai::api_keys::CustomEndpointModel;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::scene::Scene;
|
||||
use warpui::units::Pixels;
|
||||
use warpui::{App, EntityIdSet, Presenter, WindowInvalidation};
|
||||
|
||||
use super::*;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
|
||||
fn endpoint_with_models(model_count: usize) -> CustomEndpoint {
|
||||
CustomEndpoint {
|
||||
name: "Test endpoint".to_string(),
|
||||
url: "https://api.example.com/v1".to_string(),
|
||||
api_key: "key".to_string(),
|
||||
models: (0..model_count)
|
||||
.map(|index| CustomEndpointModel {
|
||||
name: format!("model-{index}"),
|
||||
alias: None,
|
||||
config_key: format!("config-{index}"),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_modal_test_models(app: &mut App) {
|
||||
initialize_app_for_terminal_view(app);
|
||||
}
|
||||
fn custom_endpoint_modal_height(scene: &Scene) -> f32 {
|
||||
let rects = scene
|
||||
.layers()
|
||||
.flat_map(|layer| &layer.rects)
|
||||
.map(|rect| (rect.bounds.width(), rect.bounds.height(), rect.border.width))
|
||||
.collect::<Vec<_>>();
|
||||
rects
|
||||
.iter()
|
||||
.filter(|(width, _, _)| *width > INPUT_WIDTH && *width <= 560.)
|
||||
.map(|(_, height, _)| *height)
|
||||
.max_by(f32::total_cmp)
|
||||
.unwrap_or_else(|| panic!("custom endpoint modal rect should exist: {rects:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_resizes_with_window_and_added_models() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(1);
|
||||
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
let body = ctx.add_typed_action_view(|ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
Modal::new(Some("Edit custom endpoint".to_string()), body, ctx)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
width: Some(560.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_max_height_percentage(0.8)
|
||||
});
|
||||
let body = modal.read(&app, |modal, _| modal.body().clone());
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
let invalidation = WindowInvalidation {
|
||||
updated: EntityIdSet::from_iter([
|
||||
app.root_view_id(window_id).expect("root view should exist"),
|
||||
body.id(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
let initial_modal_height = {
|
||||
let scene = presenter.build_scene(vec2f(800., 1000.), 1., None, ctx);
|
||||
custom_endpoint_modal_height(&scene)
|
||||
};
|
||||
body.update(ctx, |body, ctx| {
|
||||
for _ in 0..20 {
|
||||
body.add_model(ctx);
|
||||
}
|
||||
});
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let expanded_modal_height = {
|
||||
let scene = presenter.build_scene(vec2f(800., 1000.), 1., None, ctx);
|
||||
custom_endpoint_modal_height(&scene)
|
||||
};
|
||||
let small_window_height = {
|
||||
let scene = presenter.build_scene(vec2f(800., 500.), 1., None, ctx);
|
||||
custom_endpoint_modal_height(&scene)
|
||||
};
|
||||
|
||||
assert!(
|
||||
expanded_modal_height > initial_modal_height,
|
||||
"expanded modal height {expanded_modal_height} should be greater than initial modal height {initial_modal_height}"
|
||||
);
|
||||
assert!(
|
||||
(expanded_modal_height - 765.).abs() < 0.1,
|
||||
"expanded modal height {expanded_modal_height} should reach the 80% window-height cap"
|
||||
);
|
||||
assert!(
|
||||
small_window_height < expanded_modal_height,
|
||||
"small modal height {small_window_height} should be less than expanded modal height {expanded_modal_height}"
|
||||
);
|
||||
assert!(
|
||||
(small_window_height - 365.).abs() < 0.1,
|
||||
"small modal height {small_window_height} should reach the 80% window-height cap"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_with_many_models_lays_out() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(20);
|
||||
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
|
||||
assert_eq!(modal.as_ref(ctx).model_rows.len(), 20);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_row_inputs_align_and_controls_fit_gutter() {
|
||||
assert_eq!(MODEL_INPUT_WIDTH * 2. + MODEL_ROW_SPACING, INPUT_WIDTH);
|
||||
// SCROLL_CONTENT_RIGHT_MARGIN already includes MODAL_SCROLLBAR_WIDTH, so the
|
||||
// right gutter (button spacing + remove-button column + content right margin)
|
||||
// is 56 without adding the scrollbar width again.
|
||||
assert_eq!(
|
||||
REMOVE_MODEL_BUTTON_SPACING + REMOVE_MODEL_BUTTON_COL_WIDTH + SCROLL_CONTENT_RIGHT_MARGIN,
|
||||
56.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_row_remains_fixed_when_form_scrolls() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(20);
|
||||
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
let body = ctx.add_typed_action_view(|ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
Modal::new(Some("Edit custom endpoint".to_string()), body, ctx)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
width: Some(560.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_max_height_percentage(0.8)
|
||||
});
|
||||
let body = modal.read(&app, |modal, _| modal.body().clone());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated: EntityIdSet::from_iter([
|
||||
app.root_view_id(window_id).expect("root view should exist"),
|
||||
body.id(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let action_row_position = app.update(|ctx| {
|
||||
let presenter = ctx.presenter(window_id).expect("presenter should exist");
|
||||
let mut presenter = presenter.borrow_mut();
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
presenter
|
||||
.position_cache()
|
||||
.get_position(ACTIONS_POSITION_ID)
|
||||
.expect("action row position should exist")
|
||||
});
|
||||
body.update(&mut app, |body, ctx| {
|
||||
body.scroll_state.scroll_to(Pixels::new(f32::MAX));
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let scrolled_action_row_position = app.update(|ctx| {
|
||||
let presenter = ctx.presenter(window_id).expect("presenter should exist");
|
||||
let mut presenter = presenter.borrow_mut();
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
presenter.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
presenter
|
||||
.position_cache()
|
||||
.get_position(ACTIONS_POSITION_ID)
|
||||
.expect("action row position should exist")
|
||||
});
|
||||
assert!(body.read(&app, |body, _| body.scroll_state.scroll_start()) > Pixels::zero());
|
||||
assert_eq!(
|
||||
action_row_position, scrolled_action_row_position,
|
||||
"action row should remain fixed while form content scrolls"
|
||||
);
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn focus_editor_scrolls_whole_form_to_field() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(20);
|
||||
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
});
|
||||
modal.update(&mut app, |modal, ctx| {
|
||||
let editor = modal
|
||||
.model_rows
|
||||
.last()
|
||||
.expect("model row should exist")
|
||||
.name_editor
|
||||
.clone();
|
||||
modal.focus_editor(&editor, ctx);
|
||||
});
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
assert!(modal.as_ref(ctx).scroll_state.scroll_start() > Pixels::zero());
|
||||
});
|
||||
let model_scroll_start = modal.read(&app, |modal, _| modal.scroll_state.scroll_start());
|
||||
modal.update(&mut app, |modal, ctx| {
|
||||
modal.focus_editor(&modal.endpoint_name_editor.clone(), ctx);
|
||||
});
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
assert!(modal.as_ref(ctx).scroll_state.scroll_start() < model_scroll_start);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_model_scrolls_only_after_form_is_full() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(1);
|
||||
let (window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
|
||||
modal.update(&mut app, |modal, ctx| modal.add_model(ctx));
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
|
||||
assert_eq!(
|
||||
modal.as_ref(ctx).scroll_state.scroll_start(),
|
||||
Pixels::zero()
|
||||
);
|
||||
});
|
||||
|
||||
modal.update(&mut app, |modal, ctx| {
|
||||
for _ in 0..20 {
|
||||
modal.add_model(ctx);
|
||||
}
|
||||
assert_eq!(modal.scroll_state.scroll_start(), Pixels::new(f32::MAX));
|
||||
});
|
||||
app.update(|ctx| {
|
||||
ctx.presenter(window_id)
|
||||
.expect("presenter should exist")
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(560., 600.), 1., None, ctx);
|
||||
let scroll_start = modal.as_ref(ctx).scroll_state.scroll_start();
|
||||
assert!(scroll_start > Pixels::zero());
|
||||
assert!(scroll_start < Pixels::new(f32::MAX));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_resets_form_scroll_position() {
|
||||
App::test((), |mut app| async move {
|
||||
init_modal_test_models(&mut app);
|
||||
let endpoint = endpoint_with_models(20);
|
||||
let (_window_id, modal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
|
||||
CustomEndpointModal::new(Some(&endpoint), Some(0), ctx)
|
||||
});
|
||||
|
||||
modal.update(&mut app, |modal, ctx| {
|
||||
modal.scroll_state.scroll_to(Pixels::new(100.));
|
||||
assert_eq!(modal.scroll_state.scroll_start(), Pixels::new(100.));
|
||||
|
||||
modal.prefill(None, None, ctx);
|
||||
|
||||
assert_eq!(modal.scroll_state.scroll_start(), Pixels::zero());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_https_with_host() {
|
||||
assert!(validate_url("https://api.example.com/v1").is_ok());
|
||||
assert!(validate_url("https://example.com").is_ok());
|
||||
assert!(validate_url("https://8.8.8.8/v1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_http() {
|
||||
assert_eq!(
|
||||
validate_url("http://api.example.com/v1"),
|
||||
Err("URL must use HTTPS")
|
||||
);
|
||||
assert_eq!(
|
||||
validate_url("http://example.com"),
|
||||
Err("URL must use HTTPS")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_ftp_and_other_schemes() {
|
||||
assert_eq!(
|
||||
validate_url("ftp://files.example.com"),
|
||||
Err("URL must use HTTPS")
|
||||
);
|
||||
assert_eq!(
|
||||
validate_url("file:///etc/passwd"),
|
||||
Err("URL must use HTTPS")
|
||||
);
|
||||
assert_eq!(
|
||||
validate_url("ws://socket.example.com"),
|
||||
Err("URL must use HTTPS")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_malformed_strings() {
|
||||
assert_eq!(validate_url("not a url"), Err("Invalid URL"));
|
||||
assert_eq!(validate_url("https://"), Err("Invalid URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_empty_host() {
|
||||
assert_eq!(validate_url("https://?query=1"), Err("Invalid URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_allows_empty_string() {
|
||||
assert!(validate_url("").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_allows_whitespace_only() {
|
||||
assert!(validate_url(" ").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_localhost_and_private_ips() {
|
||||
let error = Err("URL must not use a local or private host");
|
||||
assert_eq!(validate_url("https://localhost:8080"), error);
|
||||
assert_eq!(validate_url("https://127.0.0.1/v1"), error);
|
||||
assert_eq!(validate_url("https://0.0.0.0/v1"), error);
|
||||
assert_eq!(validate_url("https://10.0.0.1/v1"), error);
|
||||
assert_eq!(validate_url("https://172.16.0.1/v1"), error);
|
||||
assert_eq!(validate_url("https://192.168.0.1/v1"), error);
|
||||
assert_eq!(validate_url("https://169.254.0.1/v1"), error);
|
||||
assert_eq!(validate_url("https://[::1]/v1"), error);
|
||||
assert_eq!(validate_url("https://[::]/v1"), error);
|
||||
assert_eq!(validate_url("https://[fc00::1]/v1"), error);
|
||||
assert_eq!(validate_url("https://[fe80::1]/v1"), error);
|
||||
assert_eq!(validate_url("https://[::ffff:192.168.0.1]/v1"), error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_form_valid_rejects_invalid_current_url() {
|
||||
assert!(!is_endpoint_form_valid(
|
||||
"Endpoint",
|
||||
"http://api.example.com/v1",
|
||||
"key",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_form_valid_requires_non_empty_url() {
|
||||
assert!(!is_endpoint_form_valid("Endpoint", "", "key", true));
|
||||
assert!(!is_endpoint_form_valid("Endpoint", " ", "key", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_form_valid_accepts_complete_valid_form() {
|
||||
assert!(is_endpoint_form_valid(
|
||||
"Endpoint",
|
||||
"https://api.example.com/v1",
|
||||
"key",
|
||||
true
|
||||
));
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
use galaxyui::elements::{ChildView, Container, Dismiss, Empty};
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme};
|
||||
|
||||
const DIALOG_WIDTH: f32 = 450.;
|
||||
|
||||
pub enum DeleteEnvironmentConfirmationDialogEvent {
|
||||
Cancel,
|
||||
Confirm(SyncId),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeleteEnvironmentConfirmationDialogAction {
|
||||
Cancel,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
pub struct DeleteEnvironmentConfirmationDialog {
|
||||
pub(crate) visible: bool,
|
||||
pub(crate) env_id: Option<SyncId>,
|
||||
pub(crate) env_name: String,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
confirm_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl DeleteEnvironmentConfirmationDialog {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let confirm_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Delete environment", DangerPrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Confirm);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
visible: false,
|
||||
env_id: None,
|
||||
env_name: String::new(),
|
||||
cancel_button,
|
||||
confirm_button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, env_id: SyncId, env_name: String, ctx: &mut ViewContext<Self>) {
|
||||
self.env_id = Some(env_id);
|
||||
self.env_name = env_name;
|
||||
self.visible = true;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.visible = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DeleteEnvironmentConfirmationDialog {
|
||||
type Event = DeleteEnvironmentConfirmationDialogEvent;
|
||||
}
|
||||
|
||||
impl View for DeleteEnvironmentConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"DeleteEnvironmentConfirmationDialog"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if !self.visible {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let description = format!(
|
||||
"Are you sure you want to remove the {} environment?",
|
||||
self.env_name
|
||||
);
|
||||
|
||||
let dialog = Dialog::new(
|
||||
"Delete environment?".to_string(),
|
||||
Some(description),
|
||||
dialog_styles(appearance),
|
||||
)
|
||||
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
|
||||
.with_bottom_row_child(
|
||||
Container::new(ChildView::new(&self.confirm_button).finish())
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(DIALOG_WIDTH)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Dismiss::new(dialog)
|
||||
.prevent_interaction_with_other_elements()
|
||||
.on_dismiss(|ctx, _app| {
|
||||
ctx.dispatch_typed_action(DeleteEnvironmentConfirmationDialogAction::Cancel)
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for DeleteEnvironmentConfirmationDialog {
|
||||
type Action = DeleteEnvironmentConfirmationDialogAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
DeleteEnvironmentConfirmationDialogAction::Cancel => {
|
||||
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Cancel)
|
||||
}
|
||||
DeleteEnvironmentConfirmationDialogAction::Confirm => {
|
||||
if let Some(env_id) = self.env_id {
|
||||
ctx.emit(DeleteEnvironmentConfirmationDialogEvent::Confirm(env_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::{
|
||||
Border, Container, CornerRadius, DispatchEventResult, EventHandler, Flex, MainAxisAlignment,
|
||||
MouseStateHandle, ParentElement as _, Radius, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::{
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
|
||||
use super::EnvironmentsPageAction;
|
||||
use crate::editor::EditorView;
|
||||
|
||||
pub struct NewEnvironmentButtonView {
|
||||
trigger_mouse_state: MouseStateHandle,
|
||||
search_editor: ViewHandle<EditorView>,
|
||||
self_handle: WeakViewHandle<Self>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NewEnvironmentButtonAction {
|
||||
OpenSelector,
|
||||
FocusSearch,
|
||||
}
|
||||
|
||||
impl NewEnvironmentButtonView {
|
||||
pub fn new(search_editor: ViewHandle<EditorView>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
trigger_mouse_state: Default::default(),
|
||||
search_editor,
|
||||
self_handle: ctx.handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_focused(&self, app: &AppContext) -> bool {
|
||||
self.self_handle
|
||||
.upgrade(app)
|
||||
.is_some_and(|v| v.is_focused(app))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NewEnvironmentButtonView {
|
||||
type Event = ();
|
||||
}
|
||||
impl TypedActionView for NewEnvironmentButtonView {
|
||||
type Action = NewEnvironmentButtonAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
NewEnvironmentButtonAction::OpenSelector => {
|
||||
ctx.dispatch_typed_action(
|
||||
&EnvironmentsPageAction::OpenEnvironmentSetupModeSelector,
|
||||
);
|
||||
}
|
||||
NewEnvironmentButtonAction::FocusSearch => {
|
||||
ctx.focus(&self.search_editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for NewEnvironmentButtonView {
|
||||
fn ui_name() -> &'static str {
|
||||
"NewEnvironmentButton"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let is_focused = self.is_focused(app);
|
||||
|
||||
let trigger = {
|
||||
galaxyui::elements::Hoverable::new(self.trigger_mouse_state.clone(), move |s| {
|
||||
let is_hovered = s.is_hovered();
|
||||
let background = if is_hovered || is_focused {
|
||||
Some(theme.surface_3())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_spacing(4.)
|
||||
.with_child(
|
||||
Text::new(
|
||||
"New environment",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_style(Properties::default().weight(Weight::Medium))
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut container = Container::new(row.finish())
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.surface_3()));
|
||||
|
||||
if let Some(bg) = background {
|
||||
container = container.with_background(bg);
|
||||
}
|
||||
|
||||
container.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
|
||||
EventHandler::new(trigger)
|
||||
.on_keydown(move |ctx, _app, keystroke| {
|
||||
if keystroke.is_shift_tab() {
|
||||
ctx.dispatch_typed_action(NewEnvironmentButtonAction::FocusSearch);
|
||||
DispatchEventResult::StopPropagation
|
||||
} else if keystroke.is_unmodified_enter() {
|
||||
ctx.dispatch_typed_action(NewEnvironmentButtonAction::OpenSelector);
|
||||
DispatchEventResult::StopPropagation
|
||||
} else {
|
||||
DispatchEventResult::PropagateToParent
|
||||
}
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,251 +0,0 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::{
|
||||
Align, ChildView, ClippedScrollStateHandle, ClippedScrollable, CrossAxisAlignment, Dismiss,
|
||||
Element, Flex, MouseStateHandle, ParentElement, ScrollbarWidth,
|
||||
};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::ambient_agents::github_auth_url::{AuthSource, GithubAuthRedirectTarget};
|
||||
use crate::ai::cloud_environments;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::modal::MODAL_BACKDROP_OPACITY;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::settings_view::update_environment_form::{
|
||||
EnvironmentFormInitArgs, UpdateEnvironmentForm, UpdateEnvironmentFormEvent,
|
||||
};
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const DIALOG_WIDTH: f32 = 600.;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum HandoffEnvironmentCreationModalContext {
|
||||
Handoff,
|
||||
Orchestration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum HandoffEnvironmentCreationModalEvent {
|
||||
Created { env_id: SyncId },
|
||||
Cancelled,
|
||||
CreationFailed { error_message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum HandoffEnvironmentCreationModalAction {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub(crate) struct HandoffEnvironmentCreationModal {
|
||||
environment_form: ViewHandle<UpdateEnvironmentForm>,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl HandoffEnvironmentCreationModal {
|
||||
pub(crate) fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new_impl(HandoffEnvironmentCreationModalContext::Handoff, ctx)
|
||||
}
|
||||
|
||||
pub(crate) fn new_for_orchestration(ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new_impl(HandoffEnvironmentCreationModalContext::Orchestration, ctx)
|
||||
}
|
||||
|
||||
fn new_impl(
|
||||
context: HandoffEnvironmentCreationModalContext,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let environment_form = ctx.add_typed_action_view(move |ctx| {
|
||||
let mut form = UpdateEnvironmentForm::new(EnvironmentFormInitArgs::Create, ctx);
|
||||
form.set_github_auth_redirect_target(GithubAuthRedirectTarget::FocusCloudMode);
|
||||
form.set_show_header(false, ctx);
|
||||
form.set_should_handle_escape_from_editor(true);
|
||||
form.set_auth_source(AuthSource::CloudSetup);
|
||||
match context {
|
||||
HandoffEnvironmentCreationModalContext::Handoff => {}
|
||||
HandoffEnvironmentCreationModalContext::Orchestration => {
|
||||
form.configure_for_orchestration_modal(ctx);
|
||||
}
|
||||
}
|
||||
form
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&environment_form, |me, _, event, ctx| {
|
||||
me.handle_environment_form_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
environment_form,
|
||||
close_button_mouse_state: MouseStateHandle::default(),
|
||||
scroll_state: ClippedScrollStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.scroll_state = ClippedScrollStateHandle::default();
|
||||
self.environment_form.update(ctx, |form, ctx| {
|
||||
form.set_mode(EnvironmentFormInitArgs::Create, ctx);
|
||||
form.focus(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_environment_form_event(
|
||||
&mut self,
|
||||
event: &UpdateEnvironmentFormEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
UpdateEnvironmentFormEvent::Created {
|
||||
environment,
|
||||
share_with_team,
|
||||
} => {
|
||||
let owner = if *share_with_team {
|
||||
cloud_environments::owner_for_new_environment(ctx)
|
||||
} else {
|
||||
cloud_environments::owner_for_new_personal_environment(ctx)
|
||||
};
|
||||
|
||||
let Some(owner) = owner else {
|
||||
log::error!("Unable to create environment: not logged in");
|
||||
ctx.emit(HandoffEnvironmentCreationModalEvent::CreationFailed {
|
||||
error_message: "Not logged in".to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let client_id = ClientId::default();
|
||||
let create_future =
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_ambient_agent_environment_online(
|
||||
environment.clone(),
|
||||
client_id,
|
||||
owner,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.spawn(create_future, |_me, result, ctx| match result {
|
||||
Ok(server_id) => {
|
||||
let env_id = SyncId::ServerId(server_id);
|
||||
ctx.emit(HandoffEnvironmentCreationModalEvent::Created { env_id });
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to create environment for handoff: {err:#}");
|
||||
ctx.emit(HandoffEnvironmentCreationModalEvent::CreationFailed {
|
||||
error_message: err.to_string(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
UpdateEnvironmentFormEvent::Cancelled => {
|
||||
ctx.emit(HandoffEnvironmentCreationModalEvent::Cancelled);
|
||||
}
|
||||
UpdateEnvironmentFormEvent::Updated { .. }
|
||||
| UpdateEnvironmentFormEvent::DeleteRequested { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn uses_orchestration_form_configuration_for_test(&self, app: &AppContext) -> bool {
|
||||
self.environment_form
|
||||
.as_ref(app)
|
||||
.uses_orchestration_modal_configuration_for_test()
|
||||
}
|
||||
|
||||
fn render_dialog(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let close_button = icon_button(
|
||||
appearance,
|
||||
Icon::X,
|
||||
false,
|
||||
self.close_button_mouse_state.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(HandoffEnvironmentCreationModalAction::Cancel);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let form_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(ChildView::new(&self.environment_form).finish())
|
||||
.finish();
|
||||
|
||||
let scrollable_form = ClippedScrollable::vertical(
|
||||
self.scroll_state.clone(),
|
||||
form_content,
|
||||
ScrollbarWidth::Auto,
|
||||
theme.nonactive_ui_text_color().into(),
|
||||
theme.active_ui_text_color().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish();
|
||||
|
||||
let padded_form = warpui::elements::Container::new(scrollable_form)
|
||||
.with_uniform_padding(8.)
|
||||
.finish();
|
||||
|
||||
let dialog = Dialog::new(
|
||||
"Create environment".to_string(),
|
||||
None,
|
||||
dialog_styles(appearance),
|
||||
)
|
||||
.with_close_button(close_button)
|
||||
.with_child(padded_form)
|
||||
.with_width(DIALOG_WIDTH)
|
||||
.build();
|
||||
|
||||
let dialog = Dismiss::new(dialog.finish())
|
||||
.prevent_interaction_with_other_elements()
|
||||
.on_dismiss(|ctx, _app| {
|
||||
ctx.dispatch_typed_action(HandoffEnvironmentCreationModalAction::Cancel);
|
||||
})
|
||||
.finish();
|
||||
|
||||
warpui::elements::Container::new(Align::new(dialog).finish())
|
||||
.with_background_color(ColorU::new(0, 0, 0, MODAL_BACKDROP_OPACITY))
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HandoffEnvironmentCreationModal {
|
||||
type Event = HandoffEnvironmentCreationModalEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for HandoffEnvironmentCreationModal {
|
||||
type Action = HandoffEnvironmentCreationModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
HandoffEnvironmentCreationModalAction::Cancel => {
|
||||
ctx.emit(HandoffEnvironmentCreationModalEvent::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for HandoffEnvironmentCreationModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"HandoffEnvironmentCreationModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.render_dialog(appearance, app)
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(&self.environment_form);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,7 @@ use std::str::FromStr;
|
||||
use about_page::AboutPageView;
|
||||
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
|
||||
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
|
||||
use billing_and_usage_dispatch::BillingAndUsageDispatchView;
|
||||
use billing_and_usage_page::BillingAndUsagePageEvent;
|
||||
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
|
||||
use environments_page::EnvironmentsPageView;
|
||||
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::context_flag::ContextFlag;
|
||||
@@ -32,19 +29,16 @@ use galaxyui::{
|
||||
};
|
||||
use itertools::Itertools as _;
|
||||
use keybindings::KeybindingsView;
|
||||
use main_page::{MainPageAction, MainSettingsPageEvent, MainSettingsPageView};
|
||||
use mcp_servers_page::MCPServersSettingsPageView;
|
||||
use nav::{SettingsNavItem, SettingsUmbrella};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
|
||||
use referrals_page::{ReferralsPageEvent, ReferralsPageView};
|
||||
use scripting_page::ScriptingSettingsPageView;
|
||||
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
|
||||
use settings_page::{
|
||||
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
|
||||
HEADER_PADDING,
|
||||
};
|
||||
use show_blocks_view::{ShowBlocksEvent, ShowBlocksView};
|
||||
use teams_page::{TeamsPageView, TeamsPageViewEvent};
|
||||
use warpify_page::{WarpifyPageAction, WarpifyPageView};
|
||||
|
||||
@@ -77,22 +71,13 @@ mod admin_actions;
|
||||
mod agent_assisted_environment_modal;
|
||||
mod ai_page;
|
||||
mod appearance_page;
|
||||
mod billing_and_usage;
|
||||
mod billing_and_usage_dispatch;
|
||||
mod billing_and_usage_page;
|
||||
mod billing_and_usage_page_v2;
|
||||
mod code_page;
|
||||
pub(crate) mod custom_inference_modal;
|
||||
mod custom_router_view;
|
||||
mod delete_environment_confirmation_dialog;
|
||||
mod directory_color_add_picker;
|
||||
pub(crate) mod environments_page;
|
||||
mod execution_profile_view;
|
||||
mod features;
|
||||
mod features_page;
|
||||
pub(crate) mod handoff_environment_creation_modal;
|
||||
pub mod keybindings;
|
||||
mod main_page;
|
||||
pub mod mcp_servers;
|
||||
pub mod mcp_servers_page;
|
||||
mod nav;
|
||||
@@ -101,17 +86,13 @@ mod platform;
|
||||
mod platform_page;
|
||||
mod privacy;
|
||||
mod privacy_page;
|
||||
mod referrals_page;
|
||||
mod remove_custom_endpoint_confirmation_dialog;
|
||||
mod scripting_page;
|
||||
mod set_default_model_modal;
|
||||
mod settings_file_footer;
|
||||
pub(crate) mod settings_page;
|
||||
mod show_blocks_view;
|
||||
mod tab_menu;
|
||||
mod teams_page;
|
||||
mod telemetry;
|
||||
mod transfer_ownership_confirmation_modal;
|
||||
pub mod update_environment_form;
|
||||
mod warp_drive_page;
|
||||
mod warpify_page;
|
||||
@@ -119,10 +100,8 @@ mod warpify_page;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) use ai_page::cli_agent_settings_widget_id;
|
||||
pub(crate) use ai_page::custom_model_routers_widget_id;
|
||||
pub use billing_and_usage_page::create_discount_badge;
|
||||
pub use code_page::CodeSettingsPageView;
|
||||
pub use features_page::FeaturesPageAction;
|
||||
pub use main_page::handle_experiment_change;
|
||||
pub use privacy_page::PrivacyPageAction;
|
||||
pub use settings_page::{
|
||||
render_body_item_label, render_info_icon, render_input_list, render_separator, AdditionalInfo,
|
||||
@@ -245,17 +224,13 @@ pub enum SettingsViewEvent {
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum SettingsSection {
|
||||
About,
|
||||
Account,
|
||||
MCPServers,
|
||||
BillingAndUsage,
|
||||
#[default]
|
||||
Appearance,
|
||||
Features,
|
||||
Keybindings,
|
||||
Privacy,
|
||||
Referrals,
|
||||
Scripting,
|
||||
SharedBlocks,
|
||||
Teams,
|
||||
WarpDrive,
|
||||
Warpify,
|
||||
@@ -281,11 +256,6 @@ pub enum SettingsSection {
|
||||
// ── Code umbrella subpages ──
|
||||
CodeIndexing,
|
||||
EditorAndCodeReview,
|
||||
// Dead variants — pages removed from nav but files still reference these
|
||||
#[allow(dead_code)]
|
||||
CloudEnvironments,
|
||||
#[allow(dead_code)]
|
||||
OzCloudAPIKeys,
|
||||
}
|
||||
|
||||
use std::fmt::{self, Display};
|
||||
@@ -295,7 +265,6 @@ use crate::util::bindings::custom_tag_to_keystroke;
|
||||
impl Display for SettingsSection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SettingsSection::BillingAndUsage => write!(f, "Billing and usage"),
|
||||
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
|
||||
SettingsSection::MCPServers => write!(f, "MCP Servers"),
|
||||
SettingsSection::Scripting => write!(f, "Scripting"),
|
||||
@@ -381,22 +350,17 @@ impl FromStr for SettingsSection {
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"About" => Ok(Self::About),
|
||||
"Account" => Ok(Self::Account),
|
||||
"AI" => Ok(Self::AI),
|
||||
"MCP Servers" => Ok(Self::MCPServers),
|
||||
"Billing and usage" => Ok(Self::BillingAndUsage),
|
||||
"Appearance" => Ok(Self::Appearance),
|
||||
"Code" => Ok(Self::Code),
|
||||
"Features" => Ok(Self::Features),
|
||||
"Keyboard shortcuts" => Ok(Self::Keybindings),
|
||||
"Privacy" => Ok(Self::Privacy),
|
||||
"Referrals" => Ok(Self::Referrals),
|
||||
"Scripting" => Ok(Self::Scripting),
|
||||
"Shared blocks" => Ok(Self::SharedBlocks),
|
||||
"Teams" => Ok(Self::Teams),
|
||||
"Warpify" => Ok(Self::Warpify),
|
||||
"WarpDrive" | "Galaxy Drive" => Ok(Self::WarpDrive),
|
||||
// This page was called "Oz" at one point, keep for backward compatibility.
|
||||
"Oz" | "Warp Agent" | "Galaxy Agent" => Ok(Self::WarpAgent),
|
||||
"Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles),
|
||||
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
|
||||
@@ -405,9 +369,6 @@ impl FromStr for SettingsSection {
|
||||
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
|
||||
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
||||
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
||||
"CloudEnvironments" | "Environments" => Ok(Self::CloudEnvironments),
|
||||
"OzCloudAPIKeys" => Ok(Self::OzCloudAPIKeys),
|
||||
"SharedBlocks" | "Shared Blocks" => Ok(Self::SharedBlocks),
|
||||
"Experiments" => Ok(Self::Experiments),
|
||||
_ => Err(()),
|
||||
}
|
||||
@@ -640,7 +601,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
context: &ContextPredicate,
|
||||
builder: fn(SettingsAction) -> T,
|
||||
) {
|
||||
main_page::init_actions_from_parent_view(app, context, builder);
|
||||
appearance_page::init_actions_from_parent_view(app, context, builder);
|
||||
features_page::init_actions_from_parent_view(app, context, builder);
|
||||
warpify_page::init_actions_from_parent_view(app, context, builder);
|
||||
@@ -945,7 +905,6 @@ pub enum DebugSettingsAction {
|
||||
pub enum SettingsAction {
|
||||
SelectAndRefresh(SettingsSection),
|
||||
ToggleUmbrella(usize),
|
||||
MainPageToggle(MainPageAction),
|
||||
AppearancePageToggle(AppearancePageAction),
|
||||
FeaturesPageToggle(FeaturesPageAction),
|
||||
PrivacyPageToggle(PrivacyPageAction),
|
||||
@@ -1092,24 +1051,19 @@ fn next_stop_index(current: usize, len: usize, direction: CycleDirection) -> usi
|
||||
macro_rules! update_page {
|
||||
($handle:expr, $update:expr, $ctx:expr) => {
|
||||
match $handle {
|
||||
SettingsPageViewHandle::Main(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::BillingAndUsage(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::MCPServers(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::WarpDrive(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Teams(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::SharedBlocks(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Platform(handle) => $ctx.update_view(handle, $update),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1151,11 +1105,6 @@ impl SettingsView {
|
||||
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("Settings"));
|
||||
|
||||
let global_resource_handles = GlobalResourceHandlesProvider::as_ref(ctx).get().clone();
|
||||
// Main settings page with accounts info
|
||||
let main_page_handle = ctx.add_typed_action_view(MainSettingsPageView::new);
|
||||
ctx.subscribe_to_view(&main_page_handle, |me, _, event, ctx| {
|
||||
me.handle_main_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Appearance & themes page
|
||||
let appearance_page_handle = ctx.add_typed_action_view(AppearanceSettingsPageView::new);
|
||||
@@ -1182,13 +1131,6 @@ impl SettingsView {
|
||||
me.handle_ai_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Billing & Usage page (internally, this routes to the v1 or v2 version. Depending on FFs and current plan).
|
||||
let billing_and_usage_handle = ctx.add_view(BillingAndUsageDispatchView::new);
|
||||
ctx.subscribe_to_view(&billing_and_usage_handle, |me, _, event, ctx| {
|
||||
me.handle_billing_and_usage_page_event(event, ctx);
|
||||
});
|
||||
let billing_and_usage_page = SettingsPage::new(billing_and_usage_handle);
|
||||
|
||||
// Keybindings page
|
||||
let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new);
|
||||
|
||||
@@ -1263,9 +1205,7 @@ impl SettingsView {
|
||||
});
|
||||
|
||||
let mut settings_pages = vec![
|
||||
SettingsPage::new(main_page_handle),
|
||||
SettingsPage::new(ai_page_handle),
|
||||
billing_and_usage_page,
|
||||
SettingsPage::new(code_page_handle),
|
||||
SettingsPage::new(appearance_page_handle),
|
||||
SettingsPage::new(features_page_handle),
|
||||
@@ -1309,16 +1249,7 @@ impl SettingsView {
|
||||
];
|
||||
|
||||
if FeatureFlag::WarpControlCli.is_enabled() {
|
||||
let shared_blocks_index = nav_items
|
||||
.iter()
|
||||
.position(|item| {
|
||||
matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks))
|
||||
})
|
||||
.unwrap_or(nav_items.len());
|
||||
nav_items.insert(
|
||||
shared_blocks_index,
|
||||
SettingsNavItem::Page(SettingsSection::Scripting),
|
||||
);
|
||||
nav_items.push(SettingsNavItem::Page(SettingsSection::Scripting));
|
||||
}
|
||||
|
||||
// Resolve the initial page: map internal backing-page sections to their default subpage.
|
||||
@@ -1326,7 +1257,7 @@ impl SettingsView {
|
||||
Some(SettingsSection::AI) => SettingsSection::WarpAgent,
|
||||
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
|
||||
Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => {
|
||||
SettingsSection::Account
|
||||
SettingsSection::About
|
||||
}
|
||||
Some(section) if section.is_subpage() => section,
|
||||
other => other.unwrap_or_default(),
|
||||
@@ -1690,40 +1621,6 @@ impl SettingsView {
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn handle_main_page_event(
|
||||
&mut self,
|
||||
event: &MainSettingsPageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
MainSettingsPageEvent::CheckForUpdate => ctx.emit(SettingsViewEvent::CheckForUpdate),
|
||||
MainSettingsPageEvent::SignupAnonymousUser => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_billing_and_usage_page_event(
|
||||
&mut self,
|
||||
event: &BillingAndUsagePageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
BillingAndUsagePageEvent::SignupAnonymousUser => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
BillingAndUsagePageEvent::ShowToast { message, flavor } => {
|
||||
ctx.emit(SettingsViewEvent::ShowToast {
|
||||
message: message.clone(),
|
||||
flavor: *flavor,
|
||||
})
|
||||
}
|
||||
BillingAndUsagePageEvent::ShowModal => ctx.notify(),
|
||||
BillingAndUsagePageEvent::HideModal => ctx.notify(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_appearance_page_event(
|
||||
&mut self,
|
||||
event: &SettingsPageEvent,
|
||||
@@ -2025,20 +1922,18 @@ impl SettingsView {
|
||||
|
||||
fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool {
|
||||
match &settings_page.view_handle {
|
||||
SettingsPageViewHandle::Main(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Keybindings(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Features(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Appearance(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::BillingAndUsage(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::About(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::WarpDrive(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Platform(v) => v.as_ref(app).should_render(app),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -2226,9 +2121,6 @@ impl SettingsView {
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
match page_handle {
|
||||
SettingsPageViewHandle::BillingAndUsage(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content(app))
|
||||
}
|
||||
SettingsPageViewHandle::Privacy(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content())
|
||||
}
|
||||
@@ -2564,15 +2456,6 @@ impl TypedActionView for SettingsView {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SettingsAction::MainPageToggle(main_page_action) => {
|
||||
if let Some(main_page) = self.settings_page(SettingsSection::Account) {
|
||||
if let SettingsPageViewHandle::Main(view) = &main_page.view_handle {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.handle_action(main_page_action, ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsAction::AppearancePageToggle(appearance_action) => {
|
||||
if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) {
|
||||
if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle {
|
||||
|
||||
@@ -910,7 +910,7 @@ impl PlatformPageWidget {
|
||||
|
||||
impl SettingsPageMeta for PlatformPageView {
|
||||
fn section() -> SettingsSection {
|
||||
SettingsSection::OzCloudAPIKeys
|
||||
SettingsSection::About
|
||||
}
|
||||
|
||||
fn should_render(&self, ctx: &AppContext) -> bool {
|
||||
@@ -941,7 +941,7 @@ impl SettingsPageMeta for PlatformPageView {
|
||||
|
||||
impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
|
||||
fn from(view_handle: ViewHandle<PlatformPageView>) -> Self {
|
||||
SettingsPageViewHandle::OzCloudAPIKeys(view_handle)
|
||||
SettingsPageViewHandle::Platform(view_handle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,172 +0,0 @@
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
Border, ChildView, Container, CornerRadius, Dismiss, Empty, Flex, ParentElement, Radius, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme};
|
||||
|
||||
const DIALOG_WIDTH: f32 = 450.;
|
||||
|
||||
pub enum RemoveCustomEndpointConfirmationDialogEvent {
|
||||
Cancel,
|
||||
Confirm(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoveCustomEndpointConfirmationDialogAction {
|
||||
Cancel,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
pub struct RemoveCustomEndpointConfirmationDialog {
|
||||
visible: bool,
|
||||
endpoint_index: Option<usize>,
|
||||
endpoint_name: String,
|
||||
model_labels: Vec<String>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
confirm_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl RemoveCustomEndpointConfirmationDialog {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let confirm_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Remove endpoint", DangerPrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Confirm);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
visible: false,
|
||||
endpoint_index: None,
|
||||
endpoint_name: String::new(),
|
||||
model_labels: Vec::new(),
|
||||
cancel_button,
|
||||
confirm_button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
&mut self,
|
||||
endpoint_index: usize,
|
||||
endpoint_name: String,
|
||||
model_labels: Vec<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.endpoint_index = Some(endpoint_index);
|
||||
self.endpoint_name = endpoint_name;
|
||||
self.model_labels = model_labels;
|
||||
self.visible = true;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn hide(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.visible = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RemoveCustomEndpointConfirmationDialog {
|
||||
type Event = RemoveCustomEndpointConfirmationDialogEvent;
|
||||
}
|
||||
|
||||
impl View for RemoveCustomEndpointConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"RemoveCustomEndpointConfirmationDialog"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if !self.visible {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let description = "Are you sure you want to remove this endpoint? You won't be able to use its models in your agent sessions moving forward.".to_string();
|
||||
|
||||
let endpoint_title = Text::new_inline(
|
||||
self.endpoint_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let chip_border = internal_colors::fg_overlay_3(theme);
|
||||
let chip_text = theme.active_ui_text_color();
|
||||
|
||||
let chips =
|
||||
super::render_model_chips(self.model_labels.iter().cloned(), appearance, chip_text);
|
||||
|
||||
let endpoint_card = Container::new(
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(endpoint_title)
|
||||
.with_child(chips)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(12.)
|
||||
.with_background(internal_colors::fg_overlay_1(theme))
|
||||
.with_border(Border::all(1.).with_border_fill(chip_border))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish();
|
||||
|
||||
let dialog = Dialog::new(
|
||||
"Remove endpoint?".to_string(),
|
||||
Some(description),
|
||||
dialog_styles(appearance),
|
||||
)
|
||||
.with_child(endpoint_card)
|
||||
.with_bottom_row_child(ChildView::new(&self.cancel_button).finish())
|
||||
.with_bottom_row_child(
|
||||
Container::new(ChildView::new(&self.confirm_button).finish())
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(DIALOG_WIDTH)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Dismiss::new(dialog)
|
||||
.prevent_interaction_with_other_elements()
|
||||
.on_dismiss(|ctx, _app| {
|
||||
ctx.dispatch_typed_action(RemoveCustomEndpointConfirmationDialogAction::Cancel)
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RemoveCustomEndpointConfirmationDialog {
|
||||
type Action = RemoveCustomEndpointConfirmationDialogAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
RemoveCustomEndpointConfirmationDialogAction::Cancel => {
|
||||
ctx.emit(RemoveCustomEndpointConfirmationDialogEvent::Cancel)
|
||||
}
|
||||
RemoveCustomEndpointConfirmationDialogAction::Confirm => {
|
||||
if let Some(index) = self.endpoint_index {
|
||||
ctx.emit(RemoveCustomEndpointConfirmationDialogEvent::Confirm(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,17 +29,12 @@ use settings::Setting;
|
||||
use super::about_page::AboutPageView;
|
||||
use super::ai_page::{AISettingsPageAction, AISettingsPageView};
|
||||
use super::appearance_page::AppearanceSettingsPageView;
|
||||
use super::billing_and_usage_dispatch::BillingAndUsageDispatchView;
|
||||
use super::code_page::CodeSettingsPageView;
|
||||
use super::environments_page::EnvironmentsPageView;
|
||||
use super::features_page::FeaturesPageView;
|
||||
use super::keybindings::KeybindingsView;
|
||||
use super::main_page::MainSettingsPageView;
|
||||
use super::mcp_servers_page::MCPServersSettingsPageView;
|
||||
use super::privacy_page::PrivacyPageView;
|
||||
use super::referrals_page::ReferralsPageView;
|
||||
use super::scripting_page::ScriptingSettingsPageView;
|
||||
use super::show_blocks_view::ShowBlocksView;
|
||||
use super::teams_page::TeamsPageView;
|
||||
use super::warp_drive_page::WarpDriveSettingsPageView;
|
||||
use super::warpify_page::WarpifyPageView;
|
||||
@@ -100,7 +95,6 @@ pub trait SettingsPageMeta {
|
||||
/// It is required to allow for SettingsPage struct be put in the collection (ie. vector).
|
||||
#[derive(Clone)]
|
||||
pub enum SettingsPageViewHandle {
|
||||
Main(ViewHandle<MainSettingsPageView>),
|
||||
Appearance(ViewHandle<AppearanceSettingsPageView>),
|
||||
Features(ViewHandle<FeaturesPageView>),
|
||||
Keybindings(ViewHandle<KeybindingsView>),
|
||||
@@ -108,23 +102,18 @@ pub enum SettingsPageViewHandle {
|
||||
Code(ViewHandle<CodeSettingsPageView>),
|
||||
Privacy(ViewHandle<PrivacyPageView>),
|
||||
Warpify(ViewHandle<WarpifyPageView>),
|
||||
Referrals(ViewHandle<ReferralsPageView>),
|
||||
Scripting(ViewHandle<ScriptingSettingsPageView>),
|
||||
AI(ViewHandle<AISettingsPageView>),
|
||||
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
|
||||
BillingAndUsage(ViewHandle<BillingAndUsageDispatchView>),
|
||||
MCPServers(ViewHandle<MCPServersSettingsPageView>),
|
||||
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
|
||||
OzCloudAPIKeys(ViewHandle<super::platform_page::PlatformPageView>),
|
||||
Teams(ViewHandle<super::teams_page::TeamsPageView>),
|
||||
SharedBlocks(ViewHandle<ShowBlocksView>),
|
||||
Teams(ViewHandle<TeamsPageView>),
|
||||
Platform(ViewHandle<super::platform_page::PlatformPageView>),
|
||||
}
|
||||
|
||||
impl SettingsPageViewHandle {
|
||||
pub fn child_view(&self) -> Box<dyn Element> {
|
||||
use SettingsPageViewHandle::*;
|
||||
match self {
|
||||
Main(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Appearance(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Features(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Keybindings(view_handle) => ChildView::new(view_handle).finish(),
|
||||
@@ -132,16 +121,12 @@ impl SettingsPageViewHandle {
|
||||
Code(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Privacy(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Warpify(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Referrals(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Scripting(view_handle) => ChildView::new(view_handle).finish(),
|
||||
AI(view_handle) => ChildView::new(view_handle).finish(),
|
||||
BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(),
|
||||
MCPServers(view_handle) => ChildView::new(view_handle).finish(),
|
||||
WarpDrive(view_handle) => ChildView::new(view_handle).finish(),
|
||||
CloudEnvironments(view_handle) => ChildView::new(view_handle).finish(),
|
||||
OzCloudAPIKeys(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Teams(view_handle) => ChildView::new(view_handle).finish(),
|
||||
SharedBlocks(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Platform(view_handle) => ChildView::new(view_handle).finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,811 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, FixedOffset, Local};
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::color::ColorU;
|
||||
use galaxyui::elements::{
|
||||
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Dismiss, Expanded, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable,
|
||||
ScrollableElement, ScrollbarWidth, Shrinkable, Stack, UniformList, UniformListState,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use super::settings_page::{
|
||||
render_page_title, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle,
|
||||
SettingsWidget, HEADER_FONT_SIZE, PAGE_PADDING,
|
||||
};
|
||||
use super::SettingsSection;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::channel::{Channel, ChannelState};
|
||||
use crate::menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields};
|
||||
use crate::server::block::Block;
|
||||
use crate::server::server_api::block::BlockClient;
|
||||
use crate::view_components::ToastFlavor;
|
||||
|
||||
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
|
||||
|
||||
const UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT: &str =
|
||||
"Are you sure you want to unshare this block?\n\
|
||||
\nIt will no longer be accessible by link and will be permanently deleted from Warp servers.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct UserOwnedBlock {
|
||||
id: String,
|
||||
command: String,
|
||||
link_mouse_state_handle: MouseStateHandle,
|
||||
copy_button_mouse_state_handle: MouseStateHandle,
|
||||
overflow_button_mouse_state_handle: MouseStateHandle,
|
||||
unshare_request_status: UnshareBlockRequestState,
|
||||
time_started: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl From<Block> for UserOwnedBlock {
|
||||
fn from(block: Block) -> Self {
|
||||
UserOwnedBlock::new(
|
||||
block.id.unwrap_or_default(),
|
||||
block.command.unwrap_or_default(),
|
||||
block.time_started_term,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl UserOwnedBlock {
|
||||
fn new(
|
||||
id: impl Into<String>,
|
||||
command: impl Into<String>,
|
||||
time_started: DateTime<FixedOffset>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
command: command.into(),
|
||||
unshare_request_status: UnshareBlockRequestState::NotStarted,
|
||||
link_mouse_state_handle: Default::default(),
|
||||
copy_button_mouse_state_handle: Default::default(),
|
||||
overflow_button_mouse_state_handle: Default::default(),
|
||||
time_started,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_shared(&self) -> bool {
|
||||
!matches!(self.unshare_request_status, UnshareBlockRequestState::Done)
|
||||
}
|
||||
|
||||
fn block_url(&self) -> String {
|
||||
// New block IDs are 22 characters long and are accessible at /block/{id}, whereas as old
|
||||
// (hashId) block IDs are 6 characters long and are accessible at /{id}.
|
||||
let mut url = if self.id.len() == 22 {
|
||||
format!(
|
||||
"{}/block/{}",
|
||||
ChannelState::server_root_url(),
|
||||
self.id.as_str()
|
||||
)
|
||||
} else {
|
||||
format!("{}/{}", ChannelState::server_root_url(), self.id.as_str())
|
||||
};
|
||||
|
||||
// If this is a preview build, ensure the link routes to a preview build.
|
||||
if matches!(ChannelState::channel(), Channel::Preview) {
|
||||
url.push_str("?preview=true");
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn render_overflow_icon(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
|
||||
let mut hoverable =
|
||||
Hoverable::new(self.overflow_button_mouse_state_handle.clone(), |state| {
|
||||
let container = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new("bundled/svg/overflow.svg", ColorU::new(179, 186, 184, 255))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(4.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
|
||||
|
||||
let container = if state.is_clicked() || state.is_hovered() {
|
||||
container.with_background(appearance.theme().surface_2())
|
||||
} else {
|
||||
container
|
||||
};
|
||||
container.finish()
|
||||
});
|
||||
|
||||
// Disable the overflow button if the request is in flight since the user shouldn't be able
|
||||
// to unshare it again.
|
||||
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
|
||||
hoverable = hoverable.disable();
|
||||
} else {
|
||||
hoverable = hoverable.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ShowBlocksAction::OverflowClick(index));
|
||||
});
|
||||
};
|
||||
|
||||
SavePosition::new(
|
||||
hoverable.finish(),
|
||||
format!("show_blocks_view:overflow_{index}").as_str(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn copy_link_button(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
|
||||
let button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Basic,
|
||||
self.copy_button_mouse_state_handle.clone(),
|
||||
)
|
||||
.with_text_label("Copy link".into());
|
||||
|
||||
let button = if self.unshare_request_status == UnshareBlockRequestState::InFlight {
|
||||
button.disabled().build()
|
||||
} else {
|
||||
button.build().on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ShowBlocksAction::CopyUrl(block_url.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
button.finish()
|
||||
}
|
||||
|
||||
fn link_text(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
|
||||
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.label("Deleting...")
|
||||
.with_style(
|
||||
UiComponentStyles::default()
|
||||
.set_font_family_id(appearance.monospace_font_family())
|
||||
.set_font_size(14.),
|
||||
)
|
||||
.build()
|
||||
.finish()
|
||||
} else {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
block_url.clone(),
|
||||
Some(block_url),
|
||||
None,
|
||||
self.link_mouse_state_handle.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_style(UiComponentStyles::default().set_font_size(14.))
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
|
||||
let block_url = self.block_url();
|
||||
let command = appearance
|
||||
.ui_builder()
|
||||
.label(self.command.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
let command_row = Container::new(
|
||||
Flex::row()
|
||||
.with_child(Shrinkable::new(1., Align::new(command).left().finish()).finish())
|
||||
.with_child(
|
||||
Container::new(self.render_overflow_icon(appearance, index))
|
||||
.with_padding_left(15.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
let url_row = Container::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(self.link_text(appearance, block_url.clone())).finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
0.3,
|
||||
Container::new(
|
||||
Align::new(self.copy_link_button(appearance, block_url))
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
let timestamp_row = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.label(format!(
|
||||
"Executed on: {}",
|
||||
self.time_started
|
||||
.with_timezone(&Local)
|
||||
.format("%a, %b %-d %Y at %-I:%M %p")
|
||||
))
|
||||
.with_style(
|
||||
UiComponentStyles::default()
|
||||
.set_font_color(
|
||||
appearance
|
||||
.theme()
|
||||
.hint_text_color(appearance.theme().surface_2())
|
||||
.into(),
|
||||
)
|
||||
// make it slightly smaller than the url text
|
||||
.set_font_size(appearance.ui_builder().ui_font_size() - 2.),
|
||||
)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::column()
|
||||
.with_child(Shrinkable::new(1.0, command_row).finish())
|
||||
.with_child(Shrinkable::new(1., url_row).finish())
|
||||
.with_child(timestamp_row)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(90.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Status for the request to fetch the blocks owned by the current user.
|
||||
#[derive(Debug)]
|
||||
enum GetBlocksForUserRequestState {
|
||||
NotStarted,
|
||||
InFlight,
|
||||
Failed,
|
||||
Done(Vec<UserOwnedBlock>),
|
||||
}
|
||||
|
||||
fn pad(element: Box<dyn Element>) -> Box<dyn Element> {
|
||||
Container::new(element)
|
||||
.with_uniform_padding(PAGE_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
impl GetBlocksForUserRequestState {
|
||||
fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
list_state: UniformListState,
|
||||
scroll_state_handle: ScrollStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
match self {
|
||||
GetBlocksForUserRequestState::NotStarted => pad(ui_builder
|
||||
.label("You don't have any shared blocks yet.")
|
||||
.build()
|
||||
.finish()),
|
||||
GetBlocksForUserRequestState::InFlight => {
|
||||
pad(ui_builder.label("Getting blocks...").build().finish())
|
||||
}
|
||||
GetBlocksForUserRequestState::Failed => pad(ui_builder
|
||||
.label("Failed to load blocks. Please try again.")
|
||||
.build()
|
||||
.finish()),
|
||||
GetBlocksForUserRequestState::Done(user_blocks) => {
|
||||
let user_blocks = user_blocks.clone();
|
||||
// Only consider the unshared blocks when rendering. We don't remove them from the
|
||||
// list of blocks so that the indexing is consistent for the lifetime of the app.
|
||||
let num_visible_blocks =
|
||||
user_blocks.iter().filter(|block| block.is_shared()).count();
|
||||
|
||||
if num_visible_blocks > 0 {
|
||||
let list =
|
||||
UniformList::new(list_state, num_visible_blocks, move |range, app| {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
user_blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, block)| block.is_shared())
|
||||
.skip(range.start)
|
||||
.take(range.end - range.start)
|
||||
.enumerate()
|
||||
.map(|(visible_index, (index, user_block))| {
|
||||
let user_block_element =
|
||||
Container::new(user_block.render(appearance, index))
|
||||
.with_uniform_padding(10.);
|
||||
|
||||
// Add a background on alternating blocks.
|
||||
if visible_index % 2 == 0 {
|
||||
user_block_element
|
||||
.with_background(internal_colors::fg_overlay_1(
|
||||
appearance.theme(),
|
||||
))
|
||||
.finish()
|
||||
} else {
|
||||
user_block_element.finish()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
});
|
||||
|
||||
Scrollable::vertical(
|
||||
scroll_state_handle,
|
||||
list.finish_scrollable(),
|
||||
SCROLLBAR_WIDTH,
|
||||
Fill::Solid(appearance.theme().nonactive_ui_detail().into_solid()),
|
||||
Fill::Solid(appearance.theme().active_ui_detail().into_solid()),
|
||||
Fill::None, // Leave the background transparent
|
||||
)
|
||||
.with_padding_start(5.)
|
||||
.finish()
|
||||
} else {
|
||||
pad(ui_builder
|
||||
.label("You don't have any shared blocks yet.")
|
||||
.build()
|
||||
.finish())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status for the request to unshare a block.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum UnshareBlockRequestState {
|
||||
NotStarted,
|
||||
InFlight,
|
||||
Failed,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
scroll_state_handle: ScrollStateHandle,
|
||||
confirm_dialog_handle: MouseStateHandle,
|
||||
cancel_dialog_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// A view that lists all the blocks owned by the user.
|
||||
pub struct ShowBlocksView {
|
||||
page: PageType<Self>,
|
||||
list_state: UniformListState,
|
||||
overflow_menu: ViewHandle<Menu<ShowBlocksAction>>,
|
||||
overflow_menu_index: Option<usize>,
|
||||
get_blocks_for_user_status: GetBlocksForUserRequestState,
|
||||
pending_unshared_block_index: Option<usize>,
|
||||
block_client: Arc<dyn BlockClient>,
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ShowBlocksAction {
|
||||
CopyUrl(String),
|
||||
OverflowClick(usize),
|
||||
Unshare,
|
||||
ConfirmUnshare,
|
||||
CancelUnshare,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ShowBlocksEvent {
|
||||
ShowToast {
|
||||
message: String,
|
||||
flavor: ToastFlavor,
|
||||
},
|
||||
}
|
||||
|
||||
impl ShowBlocksView {
|
||||
pub fn new(block_client: Arc<dyn BlockClient>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let menu = ctx.add_typed_action_view(|ctx| {
|
||||
let mut menu = Menu::new().prevent_interaction_with_other_elements();
|
||||
|
||||
menu.set_items(
|
||||
vec![MenuItem::Item(
|
||||
MenuItemFields::new("Unshare").with_on_select_action(ShowBlocksAction::Unshare),
|
||||
)],
|
||||
ctx,
|
||||
);
|
||||
|
||||
menu
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
|
||||
me.handle_overflow_menu_event(event, ctx);
|
||||
});
|
||||
|
||||
let page = PageType::new_monolith(ShowBlocksWidget::default(), None, false);
|
||||
Self {
|
||||
page,
|
||||
list_state: Default::default(),
|
||||
state_handles: Default::default(),
|
||||
overflow_menu: menu,
|
||||
overflow_menu_index: None,
|
||||
get_blocks_for_user_status: GetBlocksForUserRequestState::NotStarted,
|
||||
block_client,
|
||||
pending_unshared_block_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_blocks(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !matches!(
|
||||
self.get_blocks_for_user_status,
|
||||
GetBlocksForUserRequestState::InFlight
|
||||
) {
|
||||
let block_client = self.block_client.clone();
|
||||
self.get_blocks_for_user_status = GetBlocksForUserRequestState::InFlight;
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
block_client
|
||||
.blocks_owned_by_user()
|
||||
.await
|
||||
.map(|blocks| blocks.into_iter().map(Into::into).collect())
|
||||
},
|
||||
Self::on_load_complete,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_load_complete(
|
||||
&mut self,
|
||||
result: Result<Vec<UserOwnedBlock>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(mut blocks) => {
|
||||
blocks.sort_by_key(|b| std::cmp::Reverse(b.time_started));
|
||||
|
||||
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Done(blocks)
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("Failed to fetch blocks owned by user from server");
|
||||
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Failed;
|
||||
}
|
||||
}
|
||||
ctx.notify()
|
||||
}
|
||||
|
||||
pub fn copy_url(&mut self, block_url: &str, ctx: &mut ViewContext<Self>) {
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(block_url.to_string()));
|
||||
ctx.emit(ShowBlocksEvent::ShowToast {
|
||||
message: "Link copied.".to_string(),
|
||||
flavor: ToastFlavor::Default,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn overflow_click(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
self.overflow_menu_index = Some(index);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn cancel_unshare(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.pending_unshared_block_index = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn confirm_unshare(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(selected_index) = self.pending_unshared_block_index.take() {
|
||||
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status
|
||||
{
|
||||
// Only attempt to unshare if there isn't already an inflight request to unshare
|
||||
// the block.
|
||||
let user_block = &mut blocks[selected_index];
|
||||
if !matches!(
|
||||
user_block.unshare_request_status,
|
||||
UnshareBlockRequestState::InFlight
|
||||
) {
|
||||
user_block.unshare_request_status = UnshareBlockRequestState::InFlight;
|
||||
|
||||
let block_client = self.block_client.clone();
|
||||
let block_id = user_block.id.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move { (block_client.unshare_block(block_id).await, selected_index) },
|
||||
Self::on_block_unshare_complete,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn unshare_button_click(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.pending_unshared_block_index = self.overflow_menu_index.take();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn on_block_unshare_complete(
|
||||
&mut self,
|
||||
(request_result, block_index): (Result<()>, usize),
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
log::info!(
|
||||
"on_block_unshare_complete with result {:?}",
|
||||
&request_result
|
||||
);
|
||||
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status {
|
||||
let user_block = &mut blocks[block_index];
|
||||
match request_result {
|
||||
Ok(_) => {
|
||||
ctx.emit(ShowBlocksEvent::ShowToast {
|
||||
message: "Block was successfully unshared.".to_string(),
|
||||
flavor: ToastFlavor::Success,
|
||||
});
|
||||
user_block.unshare_request_status = UnshareBlockRequestState::Done;
|
||||
}
|
||||
Err(_) => {
|
||||
ctx.emit(ShowBlocksEvent::ShowToast {
|
||||
message: "Failed to unshare block. Please try again.".to_string(),
|
||||
flavor: ToastFlavor::Error,
|
||||
});
|
||||
user_block.unshare_request_status = UnshareBlockRequestState::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_overflow_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
|
||||
if let Event::Close { via_select_item: _ } = event {
|
||||
self.overflow_menu_index = None;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ShowBlocksView {
|
||||
type Event = ShowBlocksEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for ShowBlocksView {
|
||||
type Action = ShowBlocksAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
use ShowBlocksAction::*;
|
||||
match action {
|
||||
CopyUrl(url) => self.copy_url(url, ctx),
|
||||
OverflowClick(index) => self.overflow_click(*index, ctx),
|
||||
Unshare => self.unshare_button_click(ctx),
|
||||
ConfirmUnshare => self.confirm_unshare(ctx),
|
||||
CancelUnshare => self.cancel_unshare(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ShowBlocksView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ShowBlockView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.page.render(self, app)
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsPageMeta for ShowBlocksView {
|
||||
fn section() -> SettingsSection {
|
||||
SettingsSection::SharedBlocks
|
||||
}
|
||||
|
||||
fn should_render(&self, ctx: &AppContext) -> bool {
|
||||
let is_anonymous = AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out();
|
||||
|
||||
!is_anonymous
|
||||
}
|
||||
|
||||
fn on_page_selected(&mut self, _: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.load_blocks(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<ShowBlocksView>> for SettingsPageViewHandle {
|
||||
fn from(view_handle: ViewHandle<ShowBlocksView>) -> Self {
|
||||
SettingsPageViewHandle::SharedBlocks(view_handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ShowBlocksWidget {}
|
||||
|
||||
impl ShowBlocksWidget {
|
||||
fn render_confirm_delete_block_dialog(
|
||||
&self,
|
||||
view: &ShowBlocksView,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Align::new(
|
||||
ui_builder
|
||||
.label("Unshare block")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.header_font_size()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.top_center()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.ui_font_size() * 1.16),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(9.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Align::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Basic,
|
||||
view.state_handles.cancel_dialog_handle.clone(),
|
||||
)
|
||||
.with_text_label("Cancel".into())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ShowBlocksAction::CancelUnshare,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
view.state_handles
|
||||
.confirm_dialog_handle
|
||||
.clone(),
|
||||
)
|
||||
.with_text_label("Unshare".into())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ShowBlocksAction::ConfirmUnshare,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(10.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.top_center()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(20.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_uniform_padding(20.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(220.)
|
||||
.with_width(300.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsWidget for ShowBlocksWidget {
|
||||
type View = ShowBlocksView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"shared blocks"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let element_for_state = view.get_blocks_for_user_status.render(
|
||||
appearance,
|
||||
view.list_state.clone(),
|
||||
view.state_handles.scroll_state_handle.clone(),
|
||||
);
|
||||
|
||||
let inner_container = SavePosition::new(
|
||||
Container::new(element_for_state).finish(),
|
||||
"show_blocks_view:modal",
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(inner_container);
|
||||
|
||||
if let Some(menu_index) = view.overflow_menu_index {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&view.overflow_menu).finish(),
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
format!("show_blocks_view:overflow_{menu_index}").as_str(),
|
||||
vec2f(0., 2.),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
PositionedElementAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if view.pending_unshared_block_index.is_some() {
|
||||
stack.add_positioned_child(
|
||||
Dismiss::new(
|
||||
Align::new(self.render_confirm_delete_block_dialog(view, appearance)).finish(),
|
||||
)
|
||||
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ShowBlocksAction::CancelUnshare))
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let header = render_page_title("Shared blocks", HEADER_FONT_SIZE, appearance);
|
||||
let col = Flex::column()
|
||||
.with_child(Container::new(header).with_margin_bottom(24.).finish())
|
||||
.with_child(Expanded::new(1., stack.finish()).finish());
|
||||
|
||||
col.finish()
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,6 @@ use super::settings_page::{
|
||||
SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
|
||||
};
|
||||
use super::tab_menu::Tabs;
|
||||
use super::transfer_ownership_confirmation_modal::{
|
||||
TransferOwnershipConfirmationEvent, TransferOwnershipConfirmationModal,
|
||||
};
|
||||
use super::SettingsSection;
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::appearance::Appearance;
|
||||
@@ -475,7 +472,6 @@ pub struct TeamsPageView {
|
||||
team_action_confirmation_dialog: ViewHandle<CloudActionConfirmationDialog>,
|
||||
show_team_action_confirmation_dialog: bool,
|
||||
pending_team_action_confirmation: Option<TeamActionConfirmationTarget>,
|
||||
transfer_ownership_modal_state: ModalViewState<Modal<TransferOwnershipConfirmationModal>>,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
discoverable_teams_states: Vec<DiscoverableTeamState>,
|
||||
rename_team_editor: ViewHandle<ClickableTextInput>,
|
||||
@@ -605,18 +601,7 @@ impl TypedActionView for TeamsPageView {
|
||||
self.join_team_with_team_discovery(*team_uid, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
TeamsPageAction::ShowTransferOwnershipModal {
|
||||
new_owner_email,
|
||||
new_owner_uid,
|
||||
team_uid,
|
||||
} => {
|
||||
self.show_transfer_ownership_modal(
|
||||
new_owner_email.clone(),
|
||||
*new_owner_uid,
|
||||
*team_uid,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
TeamsPageAction::ShowTransferOwnershipModal { .. } => {}
|
||||
TeamsPageAction::ToggleTeamDiscoverabilityBeforeCreation => {
|
||||
self.checkbox_value = !self.checkbox_value;
|
||||
}
|
||||
@@ -796,35 +781,6 @@ impl TeamsPageView {
|
||||
me.handle_cloud_action_confirmation_dialog_event(event, ctx);
|
||||
});
|
||||
|
||||
let transfer_ownership_modal_body =
|
||||
ctx.add_typed_action_view(|_| TransferOwnershipConfirmationModal::new());
|
||||
ctx.subscribe_to_view(&transfer_ownership_modal_body, |me, _, event, ctx| {
|
||||
me.handle_transfer_ownership_modal_event(event, ctx);
|
||||
});
|
||||
let transfer_ownership_modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(
|
||||
Some("Transfer team ownership?".to_string()),
|
||||
transfer_ownership_modal_body,
|
||||
ctx,
|
||||
)
|
||||
.with_modal_style(UiComponentStyles {
|
||||
height: Some(220.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_header_style(UiComponentStyles {
|
||||
padding: Some(Coords::uniform(24.).bottom(16.)),
|
||||
..Default::default()
|
||||
})
|
||||
.with_body_style(UiComponentStyles {
|
||||
padding: Some(Coords::uniform(24.).top(0.).bottom(12.)),
|
||||
height: Some(150.),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
ctx.subscribe_to_view(&transfer_ownership_modal, |me, _, event, ctx| {
|
||||
me.handle_transfer_ownership_modal_close_event(event, ctx);
|
||||
});
|
||||
|
||||
let member_actions_menu = ctx.add_typed_action_view(|_| Menu::new().with_drop_shadow());
|
||||
ctx.subscribe_to_view(&member_actions_menu, |me, _, event, ctx| {
|
||||
if let menu::Event::Close { .. } = event {
|
||||
@@ -861,7 +817,6 @@ impl TeamsPageView {
|
||||
team_action_confirmation_dialog,
|
||||
show_team_action_confirmation_dialog: false,
|
||||
pending_team_action_confirmation: None,
|
||||
transfer_ownership_modal_state: ModalViewState::new(transfer_ownership_modal),
|
||||
discoverable_teams_states: Vec::new(),
|
||||
rename_team_editor,
|
||||
checkbox_value: true,
|
||||
@@ -1183,59 +1138,6 @@ impl TeamsPageView {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_transfer_ownership_modal_event(
|
||||
&mut self,
|
||||
event: &TransferOwnershipConfirmationEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
TransferOwnershipConfirmationEvent::Confirm {
|
||||
new_owner_uid,
|
||||
team_uid,
|
||||
} => {
|
||||
self.set_team_member_role(*new_owner_uid, *team_uid, MembershipRole::Owner, ctx);
|
||||
self.transfer_ownership_modal_state.close();
|
||||
ctx.notify();
|
||||
}
|
||||
TransferOwnershipConfirmationEvent::Cancel => {
|
||||
self.transfer_ownership_modal_state.close();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_transfer_ownership_modal_close_event(
|
||||
&mut self,
|
||||
event: &ModalEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ModalEvent::Close => {
|
||||
self.transfer_ownership_modal_state.close();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_transfer_ownership_modal(
|
||||
&mut self,
|
||||
new_owner_email: String,
|
||||
new_owner_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.transfer_ownership_modal_state
|
||||
.view
|
||||
.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |body, ctx| {
|
||||
body.set_new_owner(new_owner_email, new_owner_uid, team_uid);
|
||||
ctx.notify();
|
||||
});
|
||||
});
|
||||
self.transfer_ownership_modal_state.open();
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_approve_domains_block_editor_event(
|
||||
&mut self,
|
||||
event: &WordBlockEditorViewEvent,
|
||||
@@ -4396,17 +4298,6 @@ impl SettingsWidget for TeamsWidget {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(Flex::column().with_child(content).finish());
|
||||
|
||||
if view.transfer_ownership_modal_state.is_open() {
|
||||
stack.add_positioned_overlay_child(
|
||||
view.transfer_ownership_modal_state.render(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
}
|
||||
if view.should_show_remove_user_from_team_confirmation_dialog() {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&view.team_action_confirmation_dialog).finish(),
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use galaxyui::elements::{
|
||||
Align, Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::UserUid;
|
||||
use crate::server::ids::ServerId;
|
||||
|
||||
pub struct TransferOwnershipConfirmationModal {
|
||||
cancel_mouse_state: MouseStateHandle,
|
||||
confirm_mouse_state: MouseStateHandle,
|
||||
new_owner_email: Option<String>,
|
||||
new_owner_uid: Option<UserUid>,
|
||||
team_uid: Option<ServerId>,
|
||||
}
|
||||
|
||||
impl TransferOwnershipConfirmationModal {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancel_mouse_state: Default::default(),
|
||||
confirm_mouse_state: Default::default(),
|
||||
new_owner_email: None,
|
||||
new_owner_uid: None,
|
||||
team_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_new_owner(&mut self, email: String, user_uid: UserUid, team_uid: ServerId) {
|
||||
self.new_owner_email = Some(email);
|
||||
self.new_owner_uid = Some(user_uid);
|
||||
self.team_uid = Some(team_uid);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TransferOwnershipConfirmationModal {
|
||||
type Event = TransferOwnershipConfirmationEvent;
|
||||
}
|
||||
|
||||
impl View for TransferOwnershipConfirmationModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"TransferOwnershipConfirmationModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let email = self.new_owner_email.as_deref().unwrap_or_default();
|
||||
|
||||
let description_text = Text::new(
|
||||
format!(
|
||||
"Are you sure you want to transfer team ownership to {}? You will no longer be the owner and will not be able to take any administrative actions for this team.",
|
||||
email
|
||||
),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish();
|
||||
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
padding: Some(Coords::uniform(8.).left(12.).right(12.)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let buttons_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Secondary, self.cancel_mouse_state.clone())
|
||||
.with_text_label("Cancel".to_string())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Cancel);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
|
||||
.with_text_label("Transfer".to_string())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TransferOwnershipConfirmationAction::Confirm);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(description_text)
|
||||
.with_margin_bottom(24.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Align::new(buttons_row).right().finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TransferOwnershipConfirmationEvent {
|
||||
Confirm {
|
||||
new_owner_uid: UserUid,
|
||||
team_uid: ServerId,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransferOwnershipConfirmationAction {
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl TypedActionView for TransferOwnershipConfirmationModal {
|
||||
type Action = TransferOwnershipConfirmationAction;
|
||||
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: &TransferOwnershipConfirmationAction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match action {
|
||||
TransferOwnershipConfirmationAction::Confirm => {
|
||||
let (Some(new_owner_uid), Some(team_uid)) = (self.new_owner_uid, self.team_uid)
|
||||
else {
|
||||
log::error!("Transfer ownership confirm button pressed with no new owner set");
|
||||
return;
|
||||
};
|
||||
ctx.emit(TransferOwnershipConfirmationEvent::Confirm {
|
||||
new_owner_uid,
|
||||
team_uid,
|
||||
});
|
||||
}
|
||||
TransferOwnershipConfirmationAction::Cancel => {
|
||||
ctx.emit(TransferOwnershipConfirmationEvent::Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user