Remove Warp cloud features and OpenTelemetry
Settings cleanup: - Remove billing_and_usage, main_page, referrals_page, show_blocks_view, environments_page, handoff_environment_creation_modal, custom_inference_modal, remove_custom_endpoint_confirmation_dialog, transfer_ownership_confirmation_modal, delete_environment_confirmation_dialog - Remove SettingsSection variants: Account, BillingAndUsage, Referrals, SharedBlocks, CloudEnvironments, OzCloudAPIKeys - Remove SettingsPageViewHandle variants: Main, BillingAndUsage, Referrals, CloudEnvironments, OzCloudAPIKeys, SharedBlocks - Add Platform variant for PlatformPageView - Gut custom inference endpoint UI from ai_page.rs - Gut transfer ownership modal from teams_page.rs - Stub environment_management_pane as dead code - Remove create_discount_badge usage - Remove handle_experiment_change call OpenTelemetry removal: - Remove opentelemetry, opentelemetry-http, opentelemetry-otlp, opentelemetry_sdk, tracing-opentelemetry dependencies - Replace tracing module with no-op stub - Delete native.rs and cloud_agent_auth.rs Bug fixes (prior work): - Fix apply_diffs() to use markdown_unescaped(ctx) - Fix notebook executor AIDocumentId handling - Fix margin/corner-radius consistency in requested_command.rs - Add document tool handlers to extract_tool_result_content() - Fix deprecated from_byte_stream in MCP SSE transport - Fix Cargo.toml profile package spec - Upgrade rust-toolchain to 1.94.1
This commit is contained in:
@@ -321,13 +321,8 @@ http_server.workspace = true
|
||||
hyper.workspace = true
|
||||
libsqlite3-sys = { version = "0.33.0", features = ["bundled"] }
|
||||
mio = { version = "1.1.1", features = ["os-poll", "os-ext"] }
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-http.workspace = true
|
||||
opentelemetry-otlp.workspace = true
|
||||
opentelemetry_sdk.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
# AWS SDK (loading credentials for BYO LLM)
|
||||
|
||||
@@ -1718,6 +1718,69 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
|
||||
None => ("User did not answer.".to_string(), true),
|
||||
}
|
||||
}
|
||||
api::request::input::tool_call_result::Result::CreateDocuments(create_result) => {
|
||||
match &create_result.result {
|
||||
Some(api::create_documents_result::Result::Success(success)) => {
|
||||
let docs_info: Vec<String> = success
|
||||
.created_documents
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
format!(
|
||||
"Created document (id: {}):\n{}",
|
||||
doc.document_id, doc.content
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(docs_info.join("\n\n"), false)
|
||||
}
|
||||
Some(api::create_documents_result::Result::Error(error)) => {
|
||||
(format!("Error creating documents: {}", error.message), true)
|
||||
}
|
||||
None => ("Create documents cancelled.".to_string(), true),
|
||||
}
|
||||
}
|
||||
api::request::input::tool_call_result::Result::EditDocuments(edit_result) => {
|
||||
match &edit_result.result {
|
||||
Some(api::edit_documents_result::Result::Success(success)) => {
|
||||
let docs_info: Vec<String> = success
|
||||
.updated_documents
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
format!(
|
||||
"Updated document (id: {}):\n{}",
|
||||
doc.document_id, doc.content
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(docs_info.join("\n\n"), false)
|
||||
}
|
||||
Some(api::edit_documents_result::Result::Error(error)) => {
|
||||
(format!("Error editing documents: {}", error.message), true)
|
||||
}
|
||||
None => ("Edit documents cancelled.".to_string(), true),
|
||||
}
|
||||
}
|
||||
api::request::input::tool_call_result::Result::ReadDocuments(read_result) => {
|
||||
match &read_result.result {
|
||||
Some(api::read_documents_result::Result::Success(success)) => {
|
||||
let docs_info: Vec<String> = success
|
||||
.documents
|
||||
.iter()
|
||||
.map(|doc| {
|
||||
format!(
|
||||
"Document (id: {}):\n{}",
|
||||
doc.document_id, doc.content
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(docs_info.join("\n\n"), false)
|
||||
}
|
||||
Some(api::read_documents_result::Result::Error(error)) => {
|
||||
(format!("Error reading documents: {}", error.message), true)
|
||||
}
|
||||
None => ("Read documents cancelled.".to_string(), true),
|
||||
}
|
||||
}
|
||||
_ => ("Tool completed successfully.".to_string(), false),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::ai::agent::{
|
||||
DocumentContext, EditDocumentsRequest, EditDocumentsResult, ReadDocumentsRequest,
|
||||
ReadDocumentsResult,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
@@ -59,10 +59,11 @@ impl NotebookExecutor {
|
||||
|
||||
for document in documents {
|
||||
let client_id = ClientId::new();
|
||||
let document_id = AIDocumentId::new();
|
||||
let model = CloudNotebookModel {
|
||||
title: document.title.clone(),
|
||||
data: document.content.clone(),
|
||||
ai_document_id: None,
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
@@ -79,7 +80,7 @@ impl NotebookExecutor {
|
||||
});
|
||||
|
||||
created.push(DocumentContext {
|
||||
document_id: crate::ai::document::ai_document_model::AIDocumentId::new(),
|
||||
document_id,
|
||||
document_version: AIDocumentVersion::default(),
|
||||
content: document.content.clone(),
|
||||
line_ranges: vec![],
|
||||
@@ -112,10 +113,15 @@ impl NotebookExecutor {
|
||||
let mut documents = Vec::new();
|
||||
|
||||
for id in document_ids {
|
||||
// Try to find the notebook by treating the ID as a SyncId string
|
||||
// Look up the notebook by its ai_document_id field, falling back to SyncId matching
|
||||
let notebook = cloud_model
|
||||
.get_all_active_notebooks()
|
||||
.find(|nb| nb.id.uid() == id.to_string());
|
||||
.find(|nb| nb.model().ai_document_id.as_ref() == Some(id))
|
||||
.or_else(|| {
|
||||
cloud_model
|
||||
.get_all_active_notebooks()
|
||||
.find(|nb| nb.id.uid() == id.to_string())
|
||||
});
|
||||
|
||||
if let Some(notebook) = notebook {
|
||||
documents.push(DocumentContext {
|
||||
@@ -149,9 +155,15 @@ impl NotebookExecutor {
|
||||
|
||||
for diff in diffs {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
// Look up the notebook by its ai_document_id field, falling back to SyncId matching
|
||||
let notebook_data = cloud_model
|
||||
.get_all_active_notebooks()
|
||||
.find(|nb| nb.id.uid() == diff.document_id.to_string())
|
||||
.find(|nb| nb.model().ai_document_id.as_ref() == Some(&diff.document_id))
|
||||
.or_else(|| {
|
||||
cloud_model
|
||||
.get_all_active_notebooks()
|
||||
.find(|nb| nb.id.uid() == diff.document_id.to_string())
|
||||
})
|
||||
.map(|nb| (nb.id, nb.model().data.clone()));
|
||||
|
||||
let Some((notebook_id, current_data)) = notebook_data else {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
use warpui::elements::{ChildView, Element, Empty};
|
||||
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::settings_view::handoff_environment_creation_modal::{
|
||||
HandoffEnvironmentCreationModal, HandoffEnvironmentCreationModalEvent,
|
||||
};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::ToastStack;
|
||||
use warpui::elements::{Element, Empty};
|
||||
use warpui::{AppContext, Entity, TypedActionView, View, ViewContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CreateEnvironmentModalEvent {
|
||||
@@ -15,38 +9,11 @@ pub enum CreateEnvironmentModalEvent {
|
||||
|
||||
pub struct CreateEnvironmentModal {
|
||||
visible: bool,
|
||||
handoff_modal: ViewHandle<HandoffEnvironmentCreationModal>,
|
||||
}
|
||||
|
||||
impl CreateEnvironmentModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let handoff_modal =
|
||||
ctx.add_typed_action_view(HandoffEnvironmentCreationModal::new_for_orchestration);
|
||||
ctx.subscribe_to_view(&handoff_modal, |me, _, event, ctx| match event {
|
||||
HandoffEnvironmentCreationModalEvent::Created { env_id } => {
|
||||
me.visible = false;
|
||||
ctx.emit(CreateEnvironmentModalEvent::Created {
|
||||
environment_id: env_id.uid(),
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::Cancelled => {
|
||||
me.cancel(ctx);
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::CreationFailed { error_message } => {
|
||||
me.visible = false;
|
||||
me.show_error_toast(
|
||||
format!("Failed to create environment: {error_message}"),
|
||||
ctx,
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
visible: false,
|
||||
handoff_modal,
|
||||
}
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self { visible: false }
|
||||
}
|
||||
|
||||
pub fn is_visible(&self) -> bool {
|
||||
@@ -55,10 +22,7 @@ impl CreateEnvironmentModal {
|
||||
|
||||
pub fn show(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.visible = true;
|
||||
self.handoff_modal.update(ctx, |modal, ctx| {
|
||||
modal.show(ctx);
|
||||
});
|
||||
ctx.focus(&self.handoff_modal);
|
||||
ctx.emit(CreateEnvironmentModalEvent::Cancelled);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
@@ -66,18 +30,6 @@ impl CreateEnvironmentModal {
|
||||
self.visible = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn cancel(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.hide(ctx);
|
||||
ctx.emit(CreateEnvironmentModalEvent::Cancelled);
|
||||
}
|
||||
|
||||
fn show_error_toast(&self, message: String, ctx: &mut ViewContext<Self>) {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CreateEnvironmentModal {
|
||||
@@ -96,11 +48,7 @@ impl View for CreateEnvironmentModal {
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
if !self.visible {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
ChildView::new(&self.handoff_modal).finish()
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1476,9 +1476,8 @@ impl View for RequestedCommandView {
|
||||
let is_input_pinned_to_top =
|
||||
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
|
||||
|
||||
// When expanded details are rendered using a regular block, having a non-zero horizontal
|
||||
// margin while toggled expanded will cause the body to look wider than the header.
|
||||
// The expanded details should also appear connected to the header, so we remove bottom margin in this case.
|
||||
// Tracks whether the expanded command has a terminal output block directly below it.
|
||||
// Used to remove the bottom margin for visual continuity with the terminal block.
|
||||
let is_rendered_above_expanded_command_block = {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
|
||||
@@ -1517,7 +1516,6 @@ impl View for RequestedCommandView {
|
||||
let header_element = self.render_header(
|
||||
!should_render_editor
|
||||
&& !should_render_mcp_content
|
||||
&& !is_rendered_above_expanded_command_block
|
||||
&& !has_citations_footer,
|
||||
app,
|
||||
);
|
||||
@@ -1613,12 +1611,9 @@ impl View for RequestedCommandView {
|
||||
theme.surface_2()
|
||||
};
|
||||
|
||||
// If the requested command state is completed and input isn't pinned to the top, we're
|
||||
// going to have a regular block directly below this one with the output of the executed
|
||||
// command. Since we can't control the top padding of the AI block that comes _after_ the
|
||||
// subsequent regular block, we'll simply need to eliminate the bottom margin on this block
|
||||
// and have the next AI block take care of the vertical spacing. Moreover, having a non-zero
|
||||
// bottom margin while expanded will cause the body to look disconnected from the header.
|
||||
// If the requested command is expanded above a terminal block or
|
||||
// the next exchange flows directly after, remove bottom margin for
|
||||
// visual continuity.
|
||||
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|
||||
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
|
||||
&& is_last_output_message_in_output
|
||||
@@ -1645,28 +1640,20 @@ impl View for RequestedCommandView {
|
||||
&& !is_input_pinned_to_top);
|
||||
|
||||
let container = Container::new(content.finish())
|
||||
.with_margin_left(if is_rendered_above_expanded_command_block {
|
||||
0.
|
||||
} else if action_status.is_some_and(|status| status.is_blocked()) {
|
||||
CONTENT_HORIZONTAL_PADDING
|
||||
} else {
|
||||
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
|
||||
})
|
||||
.with_margin_right(if is_rendered_above_expanded_command_block {
|
||||
0.
|
||||
} else {
|
||||
CONTENT_HORIZONTAL_PADDING
|
||||
})
|
||||
.with_margin_left(
|
||||
if action_status.is_some_and(|status| status.is_blocked()) {
|
||||
CONTENT_HORIZONTAL_PADDING
|
||||
} else {
|
||||
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
|
||||
},
|
||||
)
|
||||
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
|
||||
.with_margin_bottom(if should_remove_bottom_margin {
|
||||
0.
|
||||
} else {
|
||||
CONTENT_ITEM_VERTICAL_MARGIN
|
||||
})
|
||||
.with_corner_radius(if is_rendered_above_expanded_command_block {
|
||||
CornerRadius::with_top(Radius::Pixels(8.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(8.))
|
||||
})
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish();
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ impl View for PromptAlertView {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
"Add credits",
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::BillingAndUsage),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||
));
|
||||
} else {
|
||||
self.action_hyperlink(&state, &mut text_fragments, app);
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::code::editor_management::CodeSource;
|
||||
use crate::drive::OpenGalaxyDriveObjectSettings;
|
||||
use crate::root_view::quake_mode_window_id;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::settings_view::environments_page::EnvironmentsPage;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::tab::SelectedTabColor;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
@@ -282,9 +281,7 @@ pub enum EnvVarCollectionPaneSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EnvironmentManagementPaneSnapshot {
|
||||
pub mode: EnvironmentsPage,
|
||||
}
|
||||
pub struct EnvironmentManagementPaneSnapshot;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SettingsPaneSnapshot {
|
||||
|
||||
@@ -1804,8 +1804,10 @@ impl NotebooksEditorModel {
|
||||
diffs: Vec<ai::diff_validation::DiffDelta>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Get current markdown content
|
||||
let current_markdown = self.markdown(ctx);
|
||||
// Get current markdown content using the unescaped form so that line
|
||||
// ranges computed by fuzzy_match_diffs (which operates on
|
||||
// markdown_unescaped output) align correctly.
|
||||
let current_markdown = self.markdown_unescaped(ctx);
|
||||
|
||||
let lines: Vec<&str> = current_markdown.lines().collect();
|
||||
let mut result_lines = Vec::new();
|
||||
|
||||
@@ -8047,15 +8047,7 @@ impl View for PaneGroup {
|
||||
.environment_setup_mode_selector_handle()
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| {
|
||||
self.downcast_pane_by_id::<EnvironmentManagementPane>(pane_id)
|
||||
.and_then(|emp| {
|
||||
emp.environments_page_view(app)
|
||||
.as_ref(app)
|
||||
.environment_setup_mode_selector_handle()
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
.or_else(|| None);
|
||||
if let Some(handle) = selector_handle {
|
||||
stack.add_child(ChildView::new(&handle).finish());
|
||||
}
|
||||
@@ -8074,18 +8066,7 @@ impl View for PaneGroup {
|
||||
}
|
||||
}
|
||||
// Render agent-assisted environment modal at tab level when open.
|
||||
if let Some(pane_id) = self.pane_with_open_agent_assisted_environment_modal {
|
||||
if let Some(handle) = self
|
||||
.downcast_pane_by_id::<EnvironmentManagementPane>(pane_id)
|
||||
.and_then(|emp| {
|
||||
emp.environments_page_view(app)
|
||||
.as_ref(app)
|
||||
.agent_assisted_environment_modal_handle(app)
|
||||
.cloned()
|
||||
})
|
||||
{
|
||||
stack.add_child(ChildView::new(&handle).finish());
|
||||
}
|
||||
if let Some(_pane_id) = self.pane_with_open_agent_assisted_environment_modal {
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
|
||||
@@ -2,48 +2,26 @@ use galaxyui::{AppContext, ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::ambient_agents::github_auth_url::GithubAuthRedirectTarget;
|
||||
use crate::app_state::{EnvironmentManagementPaneSnapshot, LeafContents};
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::settings_view::environments_page::{EnvironmentsPage, EnvironmentsPageView};
|
||||
use crate::settings_view::settings_page::{PaneEventWrapper, SettingsPageEvent};
|
||||
use crate::settings_view::{SettingsView, SettingsViewEvent};
|
||||
|
||||
pub struct EnvironmentManagementPane {
|
||||
view: ViewHandle<PaneView<EnvironmentsPageView>>,
|
||||
view: ViewHandle<PaneView<SettingsView>>,
|
||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||
}
|
||||
|
||||
impl EnvironmentManagementPane {
|
||||
pub fn new(ctx: &mut ViewContext<PaneGroup>) -> Self {
|
||||
// Create the EnvironmentsPageView
|
||||
let environments_page_view = ctx.add_typed_action_view(|ctx| {
|
||||
let mut view = EnvironmentsPageView::new(ctx);
|
||||
view.set_github_auth_redirect_target(GithubAuthRedirectTarget::FocusCloudMode, ctx);
|
||||
view
|
||||
});
|
||||
let settings_view = ctx.add_typed_action_view(|ctx| SettingsView::new(None, ctx));
|
||||
let pane_configuration = settings_view.as_ref(ctx).pane_configuration();
|
||||
|
||||
Self::from_view(environments_page_view, ctx)
|
||||
}
|
||||
|
||||
pub fn from_view(
|
||||
environments_page_view: ViewHandle<EnvironmentsPageView>,
|
||||
ctx: &mut AppContext,
|
||||
) -> Self {
|
||||
let pane_configuration = environments_page_view.as_ref(ctx).pane_configuration();
|
||||
let window_id = environments_page_view.window_id(ctx);
|
||||
|
||||
let view = ctx.add_typed_action_view(window_id, |ctx| {
|
||||
let view = ctx.add_typed_action_view(|ctx| {
|
||||
let pane_id = PaneId::from_environment_management_pane_ctx(ctx);
|
||||
PaneView::new(
|
||||
pane_id,
|
||||
environments_page_view,
|
||||
(),
|
||||
pane_configuration.clone(),
|
||||
ctx,
|
||||
)
|
||||
PaneView::new(pane_id, settings_view, (), pane_configuration.clone(), ctx)
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -51,18 +29,6 @@ impl EnvironmentManagementPane {
|
||||
pane_configuration,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn environments_page_view(&self, ctx: &AppContext) -> ViewHandle<EnvironmentsPageView> {
|
||||
self.view.as_ref(ctx).child(ctx)
|
||||
}
|
||||
|
||||
/// Returns the current mode of the environment management pane.
|
||||
pub fn current_mode(&self, ctx: &AppContext) -> EnvironmentsPage {
|
||||
self.environments_page_view(ctx)
|
||||
.as_ref(ctx)
|
||||
.current_page()
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneContent for EnvironmentManagementPane {
|
||||
@@ -78,34 +44,7 @@ impl PaneContent for EnvironmentManagementPane {
|
||||
) {
|
||||
self.view
|
||||
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
|
||||
|
||||
let pane_id = self.id();
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&self.environments_page_view(ctx),
|
||||
move |pane_group, _, event, ctx| match event {
|
||||
SettingsPageEvent::Pane(pane_event_wrapper) => {
|
||||
let pane_event = match pane_event_wrapper {
|
||||
PaneEventWrapper::Close => PaneEvent::Close,
|
||||
};
|
||||
pane_group.handle_pane_event(pane_id, &pane_event, ctx);
|
||||
}
|
||||
SettingsPageEvent::EnvironmentSetupModeSelectorToggled { is_open } => {
|
||||
pane_group.pane_with_open_environment_setup_mode_selector =
|
||||
is_open.then_some(pane_id);
|
||||
ctx.notify();
|
||||
}
|
||||
SettingsPageEvent::AgentAssistedEnvironmentModalToggled { is_open } => {
|
||||
pane_group.pane_with_open_agent_assisted_environment_modal =
|
||||
is_open.then_some(pane_id);
|
||||
ctx.notify();
|
||||
}
|
||||
SettingsPageEvent::FocusModal => {
|
||||
// Not applicable when hosted in a pane.
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
|
||||
group.handle_pane_view_event(pane_id, event, ctx);
|
||||
});
|
||||
@@ -117,15 +56,11 @@ impl PaneContent for EnvironmentManagementPane {
|
||||
_detach_type: DetachType,
|
||||
ctx: &mut ViewContext<PaneGroup>,
|
||||
) {
|
||||
let environments_page_view = self.environments_page_view(ctx);
|
||||
ctx.unsubscribe_to_view(&environments_page_view);
|
||||
ctx.unsubscribe_to_view(&self.view);
|
||||
}
|
||||
|
||||
fn snapshot(&self, ctx: &AppContext) -> LeafContents {
|
||||
LeafContents::EnvironmentManagement(EnvironmentManagementPaneSnapshot {
|
||||
mode: self.current_mode(ctx),
|
||||
})
|
||||
fn snapshot(&self, _ctx: &AppContext) -> LeafContents {
|
||||
LeafContents::EnvironmentManagement(EnvironmentManagementPaneSnapshot)
|
||||
}
|
||||
|
||||
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
|
||||
@@ -133,8 +68,7 @@ impl PaneContent for EnvironmentManagementPane {
|
||||
}
|
||||
|
||||
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
|
||||
self.environments_page_view(ctx)
|
||||
.update(ctx, |view, ctx| view.focus(ctx));
|
||||
ctx.focus(&self.view);
|
||||
}
|
||||
|
||||
fn shareable_link(
|
||||
|
||||
@@ -61,7 +61,6 @@ use crate::pane_group::pane::get_started_view::GetStartedView;
|
||||
use crate::server::network_log_view::NetworkLogView;
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::settings::PaneSettings;
|
||||
use crate::settings_view::environments_page::EnvironmentsPageView;
|
||||
use crate::settings_view::SettingsView;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
use crate::terminal::TerminalView;
|
||||
@@ -214,9 +213,9 @@ impl PaneId {
|
||||
Self::new_from_ctx(IPaneType::EnvVarCollection, ctx)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`ViewContext<PaneView<EnvironmentsPageView>>`]
|
||||
/// Creates a [`PaneId`] from a [`ViewContext<PaneView<SettingsView>>`] (environment management stub)
|
||||
pub fn from_environment_management_pane_ctx(
|
||||
ctx: &ViewContext<PaneView<EnvironmentsPageView>>,
|
||||
ctx: &ViewContext<PaneView<SettingsView>>,
|
||||
) -> Self {
|
||||
Self::new_from_ctx(IPaneType::EnvironmentManagement, ctx)
|
||||
}
|
||||
@@ -312,9 +311,9 @@ impl PaneId {
|
||||
Self::new(IPaneType::EnvVarCollection, env_var_collection_view)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`PaneView<EnvironmentsPageView>`] entity ID.
|
||||
/// Creates a [`PaneId`] from a [`PaneView<SettingsView>`] entity ID (environment management stub).
|
||||
pub fn from_environment_management_pane_view(
|
||||
environment_management_pane_view: &ViewHandle<PaneView<EnvironmentsPageView>>,
|
||||
environment_management_pane_view: &ViewHandle<PaneView<SettingsView>>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
IPaneType::EnvironmentManagement,
|
||||
@@ -469,7 +468,7 @@ impl PaneId {
|
||||
ChildView::<PaneView<EnvVarCollectionView>>::with_id(self.0.pane_view_id).finish()
|
||||
}
|
||||
IPaneType::EnvironmentManagement => {
|
||||
ChildView::<PaneView<EnvironmentsPageView>>::with_id(self.0.pane_view_id).finish()
|
||||
ChildView::<PaneView<SettingsView>>::with_id(self.0.pane_view_id).finish()
|
||||
}
|
||||
IPaneType::Workflow => {
|
||||
ChildView::<PaneView<WorkflowView>>::with_id(self.0.pane_view_id).finish()
|
||||
|
||||
@@ -1264,8 +1264,6 @@ impl ServerApiProvider {
|
||||
ServerExperiments::handle(ctx).update(ctx, |state, ctx| {
|
||||
state.apply_latest_state(experiments, ctx);
|
||||
});
|
||||
|
||||
settings_view::handle_experiment_change(ctx);
|
||||
}
|
||||
|
||||
/// Constructs a new SeverApiProvider for tests.
|
||||
|
||||
@@ -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,
|
||||
@@ -708,13 +702,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>>,
|
||||
@@ -765,7 +752,6 @@ impl AISettingsPageView {
|
||||
ctx,
|
||||
);
|
||||
|
||||
me.sync_custom_endpoint_buttons(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
@@ -1075,7 +1061,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();
|
||||
});
|
||||
|
||||
@@ -1146,7 +1131,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.
|
||||
@@ -1229,7 +1213,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);
|
||||
@@ -1719,68 +1702,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);
|
||||
@@ -1813,21 +1734,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)
|
||||
});
|
||||
@@ -1959,11 +1865,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"))]
|
||||
@@ -1986,17 +1887,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
|
||||
}
|
||||
@@ -2174,336 +2067,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| {
|
||||
@@ -3635,9 +3198,6 @@ pub enum AISettingsPageAction {
|
||||
#[cfg(feature = "local_fs")]
|
||||
OpenAddCustomRouter,
|
||||
|
||||
// Custom inference
|
||||
OpenAddCustomEndpointModal,
|
||||
OpenEditCustomEndpointModal(usize),
|
||||
ConnectGrokSubscription,
|
||||
DisconnectGrokSubscription,
|
||||
|
||||
@@ -4440,12 +4000,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
|
||||
@@ -8528,11 +8082,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)
|
||||
@@ -8782,7 +8332,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,
|
||||
@@ -280,11 +255,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};
|
||||
@@ -294,7 +264,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"),
|
||||
@@ -378,22 +347,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),
|
||||
@@ -402,9 +366,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),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
@@ -636,7 +597,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);
|
||||
@@ -941,7 +901,6 @@ pub enum DebugSettingsAction {
|
||||
pub enum SettingsAction {
|
||||
SelectAndRefresh(SettingsSection),
|
||||
ToggleUmbrella(usize),
|
||||
MainPageToggle(MainPageAction),
|
||||
AppearancePageToggle(AppearancePageAction),
|
||||
FeaturesPageToggle(FeaturesPageAction),
|
||||
PrivacyPageToggle(PrivacyPageAction),
|
||||
@@ -1088,24 +1047,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),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1147,11 +1101,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);
|
||||
@@ -1178,13 +1127,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);
|
||||
|
||||
@@ -1259,9 +1201,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),
|
||||
@@ -1305,16 +1245,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.
|
||||
@@ -1322,7 +1253,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(),
|
||||
@@ -1686,40 +1617,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,
|
||||
@@ -2021,20 +1918,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,
|
||||
}
|
||||
}
|
||||
@@ -2222,9 +2117,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())
|
||||
}
|
||||
@@ -2560,15 +2452,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use galaxy_graphql::billing::AddonCreditsOption;
|
||||
use galaxy_graphql::error::BudgetExceededError;
|
||||
use galaxyui::elements::{
|
||||
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Expanded, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||
CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||
Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
|
||||
@@ -31,7 +31,6 @@ use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::telemetry::{OutOfCreditsBannerAction, TelemetryEvent};
|
||||
use crate::settings_view::create_discount_badge;
|
||||
use crate::view_components::{Dropdown, DropdownAction};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
|
||||
@@ -356,7 +355,7 @@ impl BuyCreditsBanner {
|
||||
.with_color(text_color.into())
|
||||
.finish();
|
||||
|
||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
||||
let discount_badge = Empty::new().finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
|
||||
@@ -20,7 +20,6 @@ use crate::modal::{Modal, ModalEvent, MODAL_PADDING, MODAL_WIDTH};
|
||||
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent};
|
||||
use crate::settings_view::create_discount_badge;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::view_components::{Dropdown, DropdownAction, ToastFlavor};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
@@ -182,7 +181,7 @@ impl EnableAutoReloadModalBody {
|
||||
.with_color(text_color.into())
|
||||
.finish();
|
||||
|
||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
||||
let discount_badge = Empty::new().finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
|
||||
@@ -3563,7 +3563,7 @@ impl Input {
|
||||
let buy_credits_banner = ctx.add_typed_action_view(BuyCreditsBanner::new);
|
||||
ctx.subscribe_to_view(&buy_credits_banner, |me, _, event, ctx| match event {
|
||||
BuyCreditsBannerEvent::OpenBillingAndUsage => {
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||
}
|
||||
BuyCreditsBannerEvent::RefocusInput => {
|
||||
ctx.focus(&me.editor);
|
||||
@@ -6397,7 +6397,7 @@ impl Input {
|
||||
});
|
||||
}
|
||||
PromptAlertEvent::OpenBillingAndUsagePage => {
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||
}
|
||||
PromptAlertEvent::OpenBillingPortal { team_uid } => {
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
@@ -15685,7 +15685,7 @@ impl Input {
|
||||
entrypoint: AnonymousUserSignupEntrypoint::SignUpAIPrompt,
|
||||
}),
|
||||
PromptSuggestionsEvent::OpenBillingAndUsagePage => {
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage))
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::About))
|
||||
}
|
||||
PromptSuggestionsEvent::OpenBillingPortal { team_uid } => {
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
|
||||
@@ -780,7 +780,7 @@ impl ShareBlockModal {
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ShareBlockModalAction::Close);
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPage(
|
||||
SettingsSection::SharedBlocks,
|
||||
SettingsSection::About,
|
||||
));
|
||||
});
|
||||
if matches!(self.request_state, ShareRequestState::Pending(_)) {
|
||||
|
||||
@@ -27165,7 +27165,7 @@ impl TypedActionView for TerminalView {
|
||||
});
|
||||
}
|
||||
OpenBillingAndUsagePane => {
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
||||
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||
}
|
||||
OpenAddRulePane => {
|
||||
ctx.emit(Event::OpenAddRulePane);
|
||||
|
||||
+7
-79
@@ -1,102 +1,30 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::subscriber;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod cloud_agent_auth;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const DEFAULT_EXPORT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn init() -> anyhow::Result<Initialization> {
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
install_no_subscriber()?;
|
||||
Ok(Initialization::default())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
native::init()
|
||||
subscriber::set_global_default(subscriber::NoSubscriber::new())?;
|
||||
Ok(Initialization::default())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Starts cloud-agent trace credential refresh after authenticated application services exist.
|
||||
///
|
||||
/// The exporter and dispatch credential are initialized earlier by [`init`]. This later lifecycle
|
||||
/// hook supplies the authenticated managed-secrets client needed to mint replacements without
|
||||
/// broadening tracing initialization to ordinary application processes.
|
||||
pub fn start_auth_refresh(
|
||||
client: std::sync::Arc<dyn warp_managed_secrets::client::ManagedSecretsClient>,
|
||||
ctx: &mut warpui::AppContext,
|
||||
_client: std::sync::Arc<dyn warp_managed_secrets::client::ManagedSecretsClient>,
|
||||
_ctx: &mut warpui::AppContext,
|
||||
) {
|
||||
native::start_auth_refresh(client, ctx);
|
||||
}
|
||||
|
||||
fn install_no_subscriber() -> anyhow::Result<()> {
|
||||
// Configure the global tracing subscriber to not care about any spans or
|
||||
// events.
|
||||
//
|
||||
// This is done so that we prevent the `tracing` crate from writing out log
|
||||
// lines for spans and trace events.
|
||||
subscriber::set_global_default(subscriber::NoSubscriber::new())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", derive(Default))]
|
||||
#[derive(Default)]
|
||||
pub struct Initialization {
|
||||
initialization_warning: Option<anyhow::Error>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
active_spans: Option<native::ActiveSpanRegistry>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
shutdown_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl Default for Initialization {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initialization_warning: None,
|
||||
active_spans: None,
|
||||
provider: None,
|
||||
shutdown_timeout: DEFAULT_EXPORT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Initialization {
|
||||
pub fn log_initialization_warning(&mut self) {
|
||||
if let Some(err) = self.initialization_warning.take() {
|
||||
log::warn!("Failed to initialize cloud-agent OpenTelemetry exporting: {err:#}");
|
||||
log::warn!("Tracing initialization warning: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown(&mut self) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
match (self.active_spans.take(), self.provider.take()) {
|
||||
(Some(active_spans), Some(provider)) => {
|
||||
if let Err(err) = active_spans.shutdown(&provider, self.shutdown_timeout) {
|
||||
log::warn!(
|
||||
"Failed to shut down cloud-agent OpenTelemetry exporting: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
(None, Some(provider)) => {
|
||||
if let Err(err) = provider.shutdown_with_timeout(self.shutdown_timeout) {
|
||||
log::warn!(
|
||||
"Failed to shut down cloud-agent OpenTelemetry exporting: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
(Some(_), None) | (None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn shutdown(&mut self) {}
|
||||
}
|
||||
|
||||
impl Drop for Initialization {
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
//! Provides authenticated OTLP trace transport and credential refresh for opted-in cloud agents.
|
||||
//!
|
||||
//! Dispatch bootstraps tracing with a bearer token and expiry in the process environment. The
|
||||
//! exporter is built once around [`AuthenticatedHttpClient`], which reads a shared token snapshot
|
||||
//! immediately before every request so refresh never requires rebuilding the exporter. Processes
|
||||
//! without the endpoint switch or a currently valid dispatch credential never initialize this
|
||||
//! module.
|
||||
//!
|
||||
//! Refresh begins only after the application has an authenticated managed-secrets client. A
|
||||
//! successful mint replaces the dispatch credential only after the returned JWT's unverified
|
||||
//! payload contains a string `run_id` exactly matching the immutable startup `OZ_RUN_ID`. This
|
||||
//! payload inspection is only a rejection gate; the collector remains responsible for verifying
|
||||
//! the token's signature, audience, expiry, and trusted trace resource attributes. Every refresh
|
||||
//! failure preserves the last valid credential and enters bounded jittered backoff.
|
||||
//!
|
||||
//! Tokens must never appear in diagnostics or formatted values. Cached authorization headers are
|
||||
//! marked sensitive, manual `Debug` implementations omit secrets, and token-store locks are always
|
||||
//! released before network I/O.
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use async_channel::{Receiver, Sender};
|
||||
use async_compat::Compat;
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::stream::AbortHandle;
|
||||
use http::header::{HeaderValue, AUTHORIZATION};
|
||||
use instant::Instant;
|
||||
use opentelemetry_http::{Bytes, HttpClient, HttpError, Request, Response};
|
||||
use warp_managed_secrets::client::{IdentityTokenOptions, ManagedSecretsClient, TaskIdentityToken};
|
||||
use warpui::r#async::{FutureExt as _, Timer};
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// The environment variables form the immutable dispatch-time authentication bootstrap.
|
||||
const CLOUD_AGENT_OTLP_TOKEN: &str = "WARP_CLOUD_AGENT_OTLP_TOKEN";
|
||||
const CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT: &str = "WARP_CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT";
|
||||
const OZ_RUN_ID: &str = "OZ_RUN_ID";
|
||||
/// The collector audience and requested lifetime are fixed by the cloud-agent trace contract.
|
||||
const COLLECTOR_AUDIENCE: &str = "warp-cloud-agent-otel";
|
||||
const REFRESHED_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60);
|
||||
/// Proactive refresh starts roughly twenty minutes before expiry, with jitter to spread load.
|
||||
const PROACTIVE_REFRESH_BUFFER: Duration = Duration::from_secs(20 * 60);
|
||||
const PROACTIVE_REFRESH_JITTER: Duration = Duration::from_secs(2 * 60);
|
||||
const MIN_PROACTIVE_REFRESH_DELAY: Duration = Duration::from_secs(1);
|
||||
/// Failed refreshes use bounded full-jitter exponential backoff and rate-limited diagnostics.
|
||||
const INITIAL_FAILURE_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const MAX_FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60);
|
||||
const FAILURE_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// A stalled identity-token request must release the single in-flight refresh slot.
|
||||
const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Shared dispatch authentication state between the exporter and the later refresh coordinator.
|
||||
///
|
||||
/// The optional expected run ID intentionally does not gate initial tracing: a valid dispatch
|
||||
/// credential remains usable when `OZ_RUN_ID` is missing or empty, but every refreshed credential
|
||||
/// is rejected until an immutable expected run ID is available to the replacement gate.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct AuthContext {
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
refresh_hint_sender: Sender<()>,
|
||||
refresh_hint_receiver: Arc<Mutex<Option<Receiver<()>>>>,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
/// Seeds authentication from a currently valid dispatch credential in the environment.
|
||||
///
|
||||
/// The caller treats failure as an opt-out so normal processes and partially rolled-out cloud
|
||||
/// agents retain no-op tracing behavior.
|
||||
pub(super) fn from_environment() -> anyhow::Result<Self> {
|
||||
let token =
|
||||
std::env::var(CLOUD_AGENT_OTLP_TOKEN).context("Cloud-agent OTLP token is missing")?;
|
||||
// Remove the bootstrap secret as soon as it is owned so child processes cannot inherit it.
|
||||
std::env::remove_var(CLOUD_AGENT_OTLP_TOKEN);
|
||||
let token = token.trim().to_owned();
|
||||
anyhow::ensure!(!token.is_empty(), "Cloud-agent OTLP token is empty");
|
||||
|
||||
let expires_at = std::env::var(CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT)
|
||||
.context("Cloud-agent OTLP token expiry is missing")?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(expires_at.trim())
|
||||
.context("Cloud-agent OTLP token expiry is not valid RFC3339")?;
|
||||
anyhow::ensure!(
|
||||
expires_at.offset().local_minus_utc() == 0,
|
||||
"Cloud-agent OTLP token expiry is not UTC"
|
||||
);
|
||||
let expires_at = expires_at.with_timezone(&Utc);
|
||||
anyhow::ensure!(
|
||||
expires_at > Utc::now(),
|
||||
"Cloud-agent OTLP token is already expired"
|
||||
);
|
||||
let expected_run_id = std::env::var(OZ_RUN_ID)
|
||||
.ok()
|
||||
.filter(|run_id| !run_id.trim().is_empty());
|
||||
|
||||
let token_store = TokenStore::new(token, expires_at)?;
|
||||
let (refresh_hint_sender, refresh_hint_receiver) = async_channel::bounded(1);
|
||||
Ok(Self {
|
||||
token_store,
|
||||
expected_run_id: expected_run_id.map(Into::into),
|
||||
refresh_hint_sender,
|
||||
refresh_hint_receiver: Arc::new(Mutex::new(Some(refresh_hint_receiver))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a transport sharing the latest credential while leaving the exporter itself stable.
|
||||
pub(super) fn http_client(&self) -> AuthenticatedHttpClient {
|
||||
AuthenticatedHttpClient {
|
||||
inner: reqwest::Client::new(),
|
||||
token_store: self.token_store.clone(),
|
||||
refresh_hint_sender: self.refresh_hint_sender.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers the bounded refresh-hint receiver to the one allowed coordinator.
|
||||
fn take_refresh_hint_receiver(&self) -> Option<Receiver<()>> {
|
||||
self.refresh_hint_receiver
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthContext {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthContext")
|
||||
.field("token_store", &self.token_store)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of the latest credential, stored behind a short-lived reader/writer lock.
|
||||
///
|
||||
/// Readers clone only the sensitive authorization header, and no caller holds this lock during
|
||||
/// network I/O. Replacement constructs and validates a complete snapshot before taking the write
|
||||
/// lock so failures preserve the last valid credential.
|
||||
#[derive(Clone)]
|
||||
struct TokenStore {
|
||||
inner: Arc<RwLock<TokenSnapshot>>,
|
||||
}
|
||||
|
||||
impl TokenStore {
|
||||
/// Creates the initial store from the validated dispatch credential.
|
||||
fn new(token: String, expires_at: DateTime<Utc>) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(RwLock::new(TokenSnapshot::new(token, expires_at)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a cloned sensitive header only while the current credential remains unexpired.
|
||||
fn valid_authorization_header(&self) -> Option<HeaderValue> {
|
||||
let snapshot = self.inner.read().unwrap_or_else(|err| err.into_inner());
|
||||
(snapshot.expires_at > Utc::now()).then(|| snapshot.authorization_header.clone())
|
||||
}
|
||||
|
||||
/// Atomically replaces the current snapshot only with a usable unexpired credential.
|
||||
fn replace(&self, token: String, expires_at: DateTime<Utc>) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
expires_at > Utc::now(),
|
||||
"Refreshed cloud-agent OTLP token is already expired"
|
||||
);
|
||||
let snapshot = TokenSnapshot::new(token, expires_at)?;
|
||||
*self.inner.write().unwrap_or_else(|err| err.into_inner()) = snapshot;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the exact-run rejection gate before allowing a refreshed credential to replace the
|
||||
/// dispatch or previous refresh credential.
|
||||
fn replace_refreshed(
|
||||
&self,
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
expected_run_id: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
validate_refreshed_token_run_id(&token, expected_run_id)?;
|
||||
self.replace(token, expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenStore {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let snapshot = self.inner.read().unwrap_or_else(|err| err.into_inner());
|
||||
formatter
|
||||
.debug_struct("TokenStore")
|
||||
.field("expires_at", &snapshot.expires_at)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// An already-parsed sensitive authorization header and its trusted server expiry.
|
||||
struct TokenSnapshot {
|
||||
authorization_header: HeaderValue,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TokenSnapshot {
|
||||
/// Constructs a snapshot whose header redacts its value from standard debug formatting.
|
||||
fn new(token: String, expires_at: DateTime<Utc>) -> anyhow::Result<Self> {
|
||||
let mut authorization_header = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP token cannot be used as an HTTP header"))?;
|
||||
authorization_header.set_sensitive(true);
|
||||
Ok(Self {
|
||||
authorization_header,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenSnapshot {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("TokenSnapshot")
|
||||
.field("authorization_header", &"<redacted>")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the unverified refreshed-token payload names the immutable expected run exactly.
|
||||
///
|
||||
/// This local decode never establishes token authenticity. The collector remains responsible for
|
||||
/// cryptographically verifying the token, while malformed or mismatched tokens fail closed here
|
||||
/// before replacement and leave the existing credential untouched.
|
||||
fn validate_refreshed_token_run_id(
|
||||
token: &str,
|
||||
expected_run_id: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let expected_run_id = expected_run_id
|
||||
.filter(|run_id| !run_id.trim().is_empty())
|
||||
.context("Expected cloud-agent run ID is missing or empty")?;
|
||||
|
||||
let mut segments = token.split('.');
|
||||
let _header = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
let payload = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
let _signature = segments
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.context("Refreshed cloud-agent OTLP token is not a valid JWT")?;
|
||||
anyhow::ensure!(
|
||||
segments.next().is_none(),
|
||||
"Refreshed cloud-agent OTLP token is not a valid JWT"
|
||||
);
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| anyhow!("Refreshed cloud-agent OTLP token payload is not valid base64"))?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|_| anyhow!("Refreshed cloud-agent OTLP token payload is not valid JSON"))?;
|
||||
let run_id = payload
|
||||
.get("run_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.context("Refreshed cloud-agent OTLP token has no string run ID")?;
|
||||
anyhow::ensure!(
|
||||
run_id == expected_run_id,
|
||||
"Refreshed cloud-agent OTLP token run ID does not match"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The set of errors that can occur when making an HTTP request using [`AuthenticatedHttpClient`].
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum AuthenticatedHttpError {
|
||||
#[error("No unexpired cloud-agent OTLP token is available")]
|
||||
NoValidToken,
|
||||
#[error("Cloud-agent OTLP request failed with HTTP status {0}")]
|
||||
HttpStatus(u16),
|
||||
}
|
||||
|
||||
/// An HTTP client that injects the latest valid token immediately before each request.
|
||||
///
|
||||
/// The token-store lock is released before network I/O begins. A manual `Debug` implementation
|
||||
/// prevents the client from formatting cached state, while sensitive [`HeaderValue`] instances
|
||||
/// redact request headers. Expired credentials are removed and refused rather than sent.
|
||||
pub(super) struct AuthenticatedHttpClient {
|
||||
inner: reqwest::Client,
|
||||
token_store: TokenStore,
|
||||
refresh_hint_sender: Sender<()>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthenticatedHttpClient {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthenticatedHttpClient")
|
||||
.field("token_store", &self.token_store)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticatedHttpClient {
|
||||
/// Overwrites any supplied authorization header with the latest unexpired credential.
|
||||
///
|
||||
/// Removing the supplied header first ensures an expired store fails closed rather than
|
||||
/// accidentally sending a stale or caller-provided credential.
|
||||
fn authorize_request(
|
||||
&self,
|
||||
request: &mut Request<Bytes>,
|
||||
) -> Result<(), AuthenticatedHttpError> {
|
||||
request.headers_mut().remove(AUTHORIZATION);
|
||||
let authorization = self
|
||||
.token_store
|
||||
.valid_authorization_header()
|
||||
.ok_or(AuthenticatedHttpError::NoValidToken)?;
|
||||
request.headers_mut().insert(AUTHORIZATION, authorization);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpClient for AuthenticatedHttpClient {
|
||||
async fn send_bytes(&self, mut request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
|
||||
self.authorize_request(&mut request)?;
|
||||
|
||||
let request: reqwest::Request = request.try_into()?;
|
||||
// Reqwest requires a Tokio-compatible context, while the exporter may use another executor.
|
||||
let (status, response) = Compat::new(async {
|
||||
let mut response = self.inner.execute(request).await?;
|
||||
let status = response.status();
|
||||
let response = if status.is_success() {
|
||||
let headers = std::mem::take(response.headers_mut());
|
||||
Some((headers, response.bytes().await?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok::<_, reqwest::Error>((status, response))
|
||||
})
|
||||
.await?;
|
||||
if status == http::StatusCode::UNAUTHORIZED {
|
||||
// The bounded nonblocking hint cannot recurse into or delay this export request.
|
||||
let _ = self.refresh_hint_sender.try_send(());
|
||||
}
|
||||
let Some((headers, body)) = response else {
|
||||
return Err(AuthenticatedHttpError::HttpStatus(status.as_u16()).into());
|
||||
};
|
||||
|
||||
let mut response = Response::builder().status(status).body(body)?;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the one refresh coordinator after authenticated server connectivity is available.
|
||||
///
|
||||
/// Consuming the bounded hint receiver coalesces concurrent starts, and the coordinator immediately
|
||||
/// mints once so the short-lived dispatch credential is replaced as soon as possible.
|
||||
pub(super) fn start_refresh_coordinator(
|
||||
auth_context: AuthContext,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let Some(refresh_hint_receiver) = auth_context.take_refresh_hint_receiver() else {
|
||||
return;
|
||||
};
|
||||
ctx.add_singleton_model(move |ctx| {
|
||||
AuthRefreshCoordinator::new(
|
||||
auth_context.token_store,
|
||||
auth_context.expected_run_id,
|
||||
refresh_hint_receiver,
|
||||
client,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns serialized credential minting, proactive scheduling, failure backoff, and diagnostics.
|
||||
///
|
||||
/// At most one mint is in flight and one scheduled wakeup is retained. A bounded nonblocking 401
|
||||
/// hint can accelerate refresh without recursing into or blocking the export request.
|
||||
struct AuthRefreshCoordinator {
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
refresh_in_flight: bool,
|
||||
consecutive_failures: u32,
|
||||
scheduled_refresh: Option<AbortHandle>,
|
||||
last_failure_diagnostic: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AuthRefreshCoordinator {
|
||||
/// Installs the hint stream and immediately starts the first bounded refresh request.
|
||||
fn new(
|
||||
token_store: TokenStore,
|
||||
expected_run_id: Option<Arc<str>>,
|
||||
refresh_hint_receiver: Receiver<()>,
|
||||
client: Arc<dyn ManagedSecretsClient>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut coordinator = Self {
|
||||
token_store,
|
||||
expected_run_id,
|
||||
client,
|
||||
refresh_in_flight: false,
|
||||
consecutive_failures: 0,
|
||||
scheduled_refresh: None,
|
||||
last_failure_diagnostic: None,
|
||||
};
|
||||
let _ = ctx.spawn_stream_local(
|
||||
refresh_hint_receiver,
|
||||
|coordinator, (), ctx| coordinator.start_refresh(ctx),
|
||||
|_, _| {},
|
||||
);
|
||||
coordinator.start_refresh(ctx);
|
||||
coordinator
|
||||
}
|
||||
|
||||
/// Starts one mint and coalesces all triggers while it remains in flight.
|
||||
///
|
||||
/// Each request asks for the fixed collector audience and principal-only subject, and the
|
||||
/// timeout guarantees a stalled request eventually enters the ordinary failure path.
|
||||
fn start_refresh(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.refresh_in_flight {
|
||||
return;
|
||||
}
|
||||
self.cancel_scheduled_refresh();
|
||||
self.refresh_in_flight = true;
|
||||
let client = self.client.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
client
|
||||
.issue_task_identity_token(IdentityTokenOptions {
|
||||
audience: COLLECTOR_AUDIENCE.to_owned(),
|
||||
requested_duration: REFRESHED_TOKEN_DURATION,
|
||||
subject_template: vec1::vec1!["principal".to_owned()],
|
||||
})
|
||||
.with_timeout(REFRESH_REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP authorization refresh timed out"))?
|
||||
},
|
||||
|coordinator, result, ctx| coordinator.finish_refresh(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
/// Accepts a refreshed credential only after all replacement gates succeed.
|
||||
///
|
||||
/// Any mint, timeout, expiry, header, or run-ID failure retains the last valid token and enters
|
||||
/// the same bounded retry path without logging token contents.
|
||||
fn finish_refresh(
|
||||
&mut self,
|
||||
result: anyhow::Result<TaskIdentityToken>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.refresh_in_flight = false;
|
||||
match result {
|
||||
Ok(token) => {
|
||||
let expires_at = token.expires_at;
|
||||
if self
|
||||
.token_store
|
||||
.replace_refreshed(token.token, expires_at, self.expected_run_id.as_deref())
|
||||
.is_ok()
|
||||
{
|
||||
self.consecutive_failures = 0;
|
||||
log::info!("Cloud-agent OTLP authorization refreshed");
|
||||
self.schedule_proactive_refresh(expires_at, ctx);
|
||||
} else {
|
||||
self.warn_refresh_failure();
|
||||
self.schedule_failure_retry(ctx);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.warn_refresh_failure();
|
||||
self.schedule_failure_retry(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a refresh to occur before the current token expires.
|
||||
///
|
||||
/// This leaves some buffer for retries in case the refresh fails, but also guarantees
|
||||
/// some minimum amount of time before the first refresh attempt.
|
||||
fn schedule_proactive_refresh(
|
||||
&mut self,
|
||||
expires_at: DateTime<Utc>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let jitter = PROACTIVE_REFRESH_JITTER.mul_f64(rand::random::<f64>());
|
||||
let refresh_buffer = PROACTIVE_REFRESH_BUFFER.saturating_add(jitter);
|
||||
let remaining = (expires_at - Utc::now()).to_std().unwrap_or_default();
|
||||
let delay = remaining
|
||||
.saturating_sub(refresh_buffer)
|
||||
.max(remaining.mul_f64(0.5))
|
||||
.max(MIN_PROACTIVE_REFRESH_DELAY);
|
||||
self.schedule_refresh(delay, ctx);
|
||||
}
|
||||
|
||||
/// Schedules a full-jitter exponential retry capped at five minutes.
|
||||
fn schedule_failure_retry(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let exponent = self.consecutive_failures.min(31);
|
||||
let upper_bound = INITIAL_FAILURE_BACKOFF
|
||||
.saturating_mul(1u32 << exponent)
|
||||
.min(MAX_FAILURE_BACKOFF);
|
||||
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
|
||||
let delay = upper_bound.mul_f64(rand::random::<f64>());
|
||||
self.schedule_refresh(delay, ctx);
|
||||
}
|
||||
|
||||
/// Replaces the one scheduled wakeup so proactive, retry, and hint triggers stay coalesced.
|
||||
fn schedule_refresh(&mut self, delay: Duration, ctx: &mut ModelContext<Self>) {
|
||||
self.cancel_scheduled_refresh();
|
||||
let task = ctx.spawn(
|
||||
async move {
|
||||
Timer::after(delay).await;
|
||||
},
|
||||
|coordinator, _, ctx| {
|
||||
coordinator.scheduled_refresh = None;
|
||||
coordinator.start_refresh(ctx);
|
||||
},
|
||||
);
|
||||
self.scheduled_refresh = Some(task.abort_handle());
|
||||
}
|
||||
|
||||
/// Cancels the prior wakeup without affecting a refresh already in flight.
|
||||
fn cancel_scheduled_refresh(&mut self) {
|
||||
if let Some(handle) = self.scheduled_refresh.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a local token-free failure diagnostic at most once per configured interval.
|
||||
fn warn_refresh_failure(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_failure_diagnostic
|
||||
.is_none_or(|last| now.duration_since(last) >= FAILURE_LOG_INTERVAL)
|
||||
{
|
||||
self.last_failure_diagnostic = Some(now);
|
||||
log::warn!("Cloud-agent OTLP authorization refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AuthRefreshCoordinator {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AuthRefreshCoordinator {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_agent_auth_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,157 +0,0 @@
|
||||
use base64::Engine as _;
|
||||
use chrono::TimeDelta;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
||||
let encoder = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let header = encoder.encode(br#"{"alg":"none"}"#);
|
||||
let payload = encoder.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("{header}.{payload}.test-signature")
|
||||
}
|
||||
|
||||
fn client_with_expiry(token: &str, expires_at: DateTime<Utc>) -> AuthenticatedHttpClient {
|
||||
let (refresh_hint_sender, _) = async_channel::bounded(1);
|
||||
AuthenticatedHttpClient {
|
||||
inner: reqwest::Client::new(),
|
||||
token_store: TokenStore::new(token.to_owned(), expires_at).unwrap(),
|
||||
refresh_hint_sender,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_overwrites_supplied_header() {
|
||||
let client = client_with_expiry(
|
||||
"current-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.header(AUTHORIZATION, "Bearer stale-test-token")
|
||||
.body(Bytes::new())
|
||||
.unwrap();
|
||||
|
||||
client.authorize_request(&mut request).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.headers().get(AUTHORIZATION).unwrap(),
|
||||
"Bearer current-test-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_token_is_refused_and_supplied_header_is_removed() {
|
||||
let client = client_with_expiry(
|
||||
"expired-test-token",
|
||||
Utc::now() - TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder()
|
||||
.header(AUTHORIZATION, "Bearer stale-test-token")
|
||||
.body(Bytes::new())
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.authorize_request(&mut request),
|
||||
Err(AuthenticatedHttpError::NoValidToken)
|
||||
));
|
||||
assert!(!request.headers().contains_key(AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_token() {
|
||||
let client = client_with_expiry(
|
||||
"secret-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
|
||||
let debug_output = format!("{client:?}");
|
||||
|
||||
assert!(!debug_output.contains("secret-test-token"));
|
||||
assert!(debug_output.contains("expires_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_request_debug_redacts_token() {
|
||||
let client = client_with_expiry(
|
||||
"secret-request-test-token",
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
);
|
||||
let mut request = Request::builder().body(Bytes::new()).unwrap();
|
||||
|
||||
client.authorize_request(&mut request).unwrap();
|
||||
let request_debug = format!("{request:?}");
|
||||
let headers_debug = format!("{:?}", request.headers());
|
||||
|
||||
assert!(!request_debug.contains("secret-request-test-token"));
|
||||
assert!(!headers_debug.contains("secret-request-test-token"));
|
||||
assert!(request_debug.contains("Sensitive"));
|
||||
assert!(headers_debug.contains("Sensitive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_exactly_matches() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "expected-run-id" }));
|
||||
|
||||
validate_refreshed_token_run_id(&token, Some("expected-run-id")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_is_required() {
|
||||
let token = jwt_with_payload(serde_json::json!({}));
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_run_id_is_required() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "expected-run-id" }));
|
||||
|
||||
assert!(validate_refreshed_token_run_id(&token, None).is_err());
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_must_match() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": "wrong-run-id" }));
|
||||
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_token_run_id_must_be_a_string() {
|
||||
let token = jwt_with_payload(serde_json::json!({ "run_id": 123 }));
|
||||
assert!(validate_refreshed_token_run_id(&token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_refreshed_tokens_are_rejected() {
|
||||
let invalid_json = {
|
||||
let encoder = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let payload = encoder.encode(b"not-json");
|
||||
format!("header.{payload}.signature")
|
||||
};
|
||||
|
||||
for token in ["not-a-jwt", "header.!!!.signature", &invalid_json] {
|
||||
assert!(validate_refreshed_token_run_id(token, Some("expected-run-id")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_refreshed_token_preserves_previous_token() {
|
||||
let token_store = TokenStore::new(
|
||||
"current-test-token".to_owned(),
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let wrong_run_token = jwt_with_payload(serde_json::json!({ "run_id": "wrong-run-id" }));
|
||||
|
||||
assert!(token_store
|
||||
.replace_refreshed(
|
||||
wrong_run_token,
|
||||
Utc::now() + TimeDelta::try_minutes(5).unwrap(),
|
||||
Some("expected-run-id"),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
token_store.valid_authorization_header().unwrap(),
|
||||
"Bearer current-test-token"
|
||||
);
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
//! Configures opt-in OpenTelemetry export for cloud-agent traces on native platforms.
|
||||
//!
|
||||
//! The global `tracing` subscriber observes the whole application, while
|
||||
//! [`CloudAgentSpanExporter`] limits OTLP export to spans explicitly marked with
|
||||
//! [`CLOUD_AGENT_MARKER`]. Keeping this selection at the exporter boundary lets callers use the
|
||||
//! normal `tracing` macros and propagation machinery without installing a second subscriber or
|
||||
//! coupling generic task executors to cloud-agent tracing.
|
||||
//!
|
||||
//! # Why spans must be ended during shutdown
|
||||
//!
|
||||
//! An OpenTelemetry span becomes exportable and passes owned span data to a processor's `on_end`
|
||||
//! callback only after it ends. Shutting down an [`SdkTracerProvider`] flushes spans that have
|
||||
//! reached `on_end`, but it does not end spans that are still active. Some `tracing::Span`
|
||||
//! references are intentionally propagated into asynchronous task machinery and can therefore
|
||||
//! remain alive when the application terminates. Shutting down the provider before those spans end
|
||||
//! would silently discard them.
|
||||
//!
|
||||
//! [`ShutdownAwareTracer`] and [`ShutdownAwareSpan`] wrap the SDK tracer and spans used by
|
||||
//! `tracing-opentelemetry`. This keeps existing `tracing` instrumentation unchanged while allowing
|
||||
//! [`ActiveSpanRegistry`] to explicitly end still-reachable, registered SDK spans before shutting
|
||||
//! down the provider. The standard application lifecycle retains [`Initialization`] in its
|
||||
//! termination callback so this ordering happens before platforms that terminate the process
|
||||
//! without running Rust destructors. [`Initialization`]'s `Drop` implementation remains a fallback
|
||||
//! for ordinary returns. Explicit process exits bypass both forms of cleanup.
|
||||
//!
|
||||
//! # Span ownership and synchronization
|
||||
//!
|
||||
//! `tracing-opentelemetry` creates SDK spans lazily during several `tracing` span lifecycle
|
||||
//! operations, including when it needs a span's context and when a span closes. Every SDK span that
|
||||
//! reaches [`ShutdownAwareTracer::build_with_context`] is wrapped in an `Arc<Mutex<_>>`. Before
|
||||
//! shutdown begins, it is weakly registered; after shutdown begins, it is immediately ended
|
||||
//! instead. The wrapper remains the span's owner; the registry uses weak references so tracking
|
||||
//! does not extend normal span lifetimes. An SDK span that has not yet been built when shutdown
|
||||
//! begins cannot reach `on_end` before provider shutdown. If it materializes later, it is ended too
|
||||
//! late for export.
|
||||
//!
|
||||
//! SDK-span creation and shutdown are serialized by the registry-state mutex. Shutdown keeps that
|
||||
//! mutex locked while it ends every still-upgradeable registered span and shuts down the provider,
|
||||
//! preventing an SDK span from being created in the otherwise-dangerous gap between those
|
||||
//! operations. A final span owner can begin dropping after its weak reference becomes impossible
|
||||
//! to upgrade, so shutdown cannot strictly guarantee that every previously registered span has
|
||||
//! finished ending. The lock order is always registry state followed by an individual SDK span.
|
||||
//! Normal span operations lock only the individual SDK span and never attempt to lock the registry.
|
||||
//! Mutex acquisition recovers poisoned inner values because trace export and shutdown are
|
||||
//! best-effort cleanup that should continue after an unrelated panic.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use instant::Instant;
|
||||
use opentelemetry::trace::{
|
||||
Span as _, SpanBuilder, SpanContext, Status, Tracer as _, TracerProvider as _,
|
||||
};
|
||||
use opentelemetry::{Context as OtelContext, KeyValue, Value};
|
||||
use opentelemetry_otlp::{Protocol, WithExportConfig as _, WithHttpConfig as _};
|
||||
use opentelemetry_sdk::error::OTelSdkResult;
|
||||
use opentelemetry_sdk::resource::{EnvResourceDetector, TelemetryResourceDetector};
|
||||
use opentelemetry_sdk::trace::{
|
||||
SdkTracer, SdkTracerProvider, Span as SdkSpan, SpanData, SpanExporter,
|
||||
};
|
||||
use opentelemetry_sdk::Resource;
|
||||
use tracing::subscriber;
|
||||
use tracing_subscriber::layer::SubscriberExt as _;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use url::{Host, Url};
|
||||
use warp_managed_secrets::client::ManagedSecretsClient;
|
||||
use warpui::AppContext;
|
||||
|
||||
use super::cloud_agent_auth::{self, AuthContext};
|
||||
use super::Initialization;
|
||||
use crate::channel::ChannelState;
|
||||
use crate::tracing::install_no_subscriber;
|
||||
|
||||
/// The tag used to mark spans related to cloud agents, which we use to filter out
|
||||
/// spans we don't care about (e.g.: ones from dependencies).
|
||||
const CLOUD_AGENT_MARKER: &str = "tags.cloud_agent";
|
||||
/// The environment variable used to configure the cloud agent OTLP endpoint.
|
||||
const CLOUD_AGENT_OTLP_ENDPOINT: &str = "WARP_CLOUD_AGENT_OTLP_ENDPOINT";
|
||||
/// The environment variable used to configure the OTel service name.
|
||||
const OTEL_SERVICE_NAME: &str = "OTEL_SERVICE_NAME";
|
||||
/// The minimum interval between local export failure diagnostics.
|
||||
const EXPORT_FAILURE_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Process-global authentication context for cloud-agent OTLP export.
|
||||
///
|
||||
/// The exporter is built once during [`init`], while the stored context later starts dynamic
|
||||
/// credential refresh after authenticated application services become available, and this static
|
||||
/// remains unset for processes that did not opt in.
|
||||
static AUTH_CONTEXT: OnceLock<AuthContext> = OnceLock::new();
|
||||
|
||||
/// Installs the native tracing subscriber and optional cloud-agent OTLP exporter.
|
||||
///
|
||||
/// Export is deliberately opt-in through [`CLOUD_AGENT_OTLP_ENDPOINT`] plus a currently valid
|
||||
/// dispatch token. When either is absent or the exporter cannot be constructed, a no-op subscriber
|
||||
/// is installed so tracing instrumentation remains safe without producing output or partially
|
||||
/// initializing export.
|
||||
pub fn init() -> anyhow::Result<Initialization> {
|
||||
// INFO is the default because this is a global subscriber and DEBUG-level application spans
|
||||
// would otherwise create substantial work even though only marked cloud-agent spans are
|
||||
// exported. RUST_LOG can still override this when deeper tracing is needed.
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(tracing::Level::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
let Some(base_endpoint) = std::env::var(CLOUD_AGENT_OTLP_ENDPOINT)
|
||||
.ok()
|
||||
.filter(|endpoint| !endpoint.trim().is_empty())
|
||||
else {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization::default());
|
||||
};
|
||||
let Ok(auth_context) = AuthContext::from_environment() else {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization::default());
|
||||
};
|
||||
|
||||
let shutdown_timeout = export_timeout();
|
||||
let provider = match build_provider(base_endpoint.trim(), &auth_context) {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
install_no_subscriber()?;
|
||||
return Ok(Initialization {
|
||||
initialization_warning: Some(err),
|
||||
active_spans: None,
|
||||
provider: None,
|
||||
shutdown_timeout,
|
||||
});
|
||||
}
|
||||
};
|
||||
let _ = AUTH_CONTEXT.set(auth_context);
|
||||
|
||||
let active_spans = ActiveSpanRegistry::default();
|
||||
let tracer =
|
||||
ShutdownAwareTracer::new(provider.tracer("warp-cloud-agent"), active_spans.clone());
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_opentelemetry::layer().with_tracer(tracer));
|
||||
subscriber::set_global_default(subscriber)?;
|
||||
|
||||
Ok(Initialization {
|
||||
initialization_warning: None,
|
||||
active_spans: Some(active_spans),
|
||||
provider: Some(provider),
|
||||
shutdown_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the SDK provider and its batch exporter.
|
||||
///
|
||||
/// A batch exporter keeps network export off instrumentation call sites. The provider is retained
|
||||
/// by [`Initialization`] so application termination can explicitly shut it down after attempting
|
||||
/// to end registered active spans. Exported resources include Warp's version and channel alongside
|
||||
/// standard environment-detected OpenTelemetry attributes, with [`OTEL_SERVICE_NAME`] taking
|
||||
/// precedence over the default service name. The exporter is built once with a dynamic HTTP client
|
||||
/// so credential refresh can update requests without reconstructing provider state.
|
||||
fn build_provider(
|
||||
base_endpoint: &str,
|
||||
auth_context: &AuthContext,
|
||||
) -> anyhow::Result<SdkTracerProvider> {
|
||||
let endpoint = traces_endpoint(base_endpoint)?;
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_http()
|
||||
.with_http_client(auth_context.http_client())
|
||||
.with_protocol(Protocol::HttpBinary)
|
||||
.with_endpoint(endpoint)
|
||||
.build()
|
||||
.context("Failed to build the OTLP span exporter")?;
|
||||
|
||||
let resource = Resource::builder_empty()
|
||||
.with_service_name("warp-cloud-agent")
|
||||
.with_attribute(KeyValue::new(
|
||||
"service.version",
|
||||
ChannelState::app_version().unwrap_or("<no tag>"),
|
||||
))
|
||||
.with_attribute(KeyValue::new(
|
||||
"warp.channel",
|
||||
ChannelState::channel().to_string(),
|
||||
))
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.with_detector(Box::new(EnvResourceDetector::new()));
|
||||
let resource = match std::env::var(OTEL_SERVICE_NAME) {
|
||||
Ok(service_name) if !service_name.is_empty() => resource.with_service_name(service_name),
|
||||
Ok(_) | Err(_) => resource,
|
||||
}
|
||||
.build();
|
||||
|
||||
Ok(SdkTracerProvider::builder()
|
||||
.with_batch_exporter(CloudAgentSpanExporter {
|
||||
inner: exporter,
|
||||
diagnostics: RateLimitedDiagnostics::default(),
|
||||
})
|
||||
.with_resource(resource)
|
||||
.build())
|
||||
}
|
||||
|
||||
/// Starts the single refresh coordinator after the authenticated server client exists.
|
||||
///
|
||||
/// Processes that did not opt in with both an endpoint and valid dispatch credential have no
|
||||
/// retained [`AUTH_CONTEXT`] and remain no-ops here.
|
||||
pub(super) fn start_auth_refresh(client: Arc<dyn ManagedSecretsClient>, ctx: &mut AppContext) {
|
||||
if let Some(auth_context) = AUTH_CONTEXT.get() {
|
||||
cloud_agent_auth::start_refresh_coordinator(auth_context.clone(), client, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the configured OTLP base URL into the HTTP/protobuf traces endpoint.
|
||||
///
|
||||
/// The configuration is treated as a base URL rather than a complete signal-specific URL, so any
|
||||
/// query or fragment is discarded before appending `v1/traces`. Authenticated export requires
|
||||
/// HTTPS unless the configured host is guaranteed to resolve to the local machine.
|
||||
fn traces_endpoint(base_endpoint: &str) -> anyhow::Result<String> {
|
||||
let mut endpoint = Url::parse(base_endpoint).context("Invalid cloud-agent OTLP endpoint")?;
|
||||
match endpoint.scheme() {
|
||||
"https" => {}
|
||||
"http" if endpoint_host_is_loopback(&endpoint) => {}
|
||||
"http" => {
|
||||
return Err(anyhow!(
|
||||
"Cloud-agent OTLP endpoint must use HTTPS unless its host is loopback"
|
||||
));
|
||||
}
|
||||
_ => return Err(anyhow!("Cloud-agent OTLP endpoint must use HTTP or HTTPS")),
|
||||
}
|
||||
|
||||
endpoint.set_query(None);
|
||||
endpoint.set_fragment(None);
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow!("Cloud-agent OTLP endpoint cannot be used as a base URL"))?
|
||||
.pop_if_empty()
|
||||
.extend(["v1", "traces"]);
|
||||
Ok(endpoint.into())
|
||||
}
|
||||
/// Returns whether the endpoint host is guaranteed to resolve to the local machine.
|
||||
fn endpoint_host_is_loopback(endpoint: &Url) -> bool {
|
||||
match endpoint.host() {
|
||||
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the export shutdown timeout using the standard OpenTelemetry environment variables.
|
||||
fn export_timeout() -> Duration {
|
||||
[
|
||||
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
|
||||
"OTEL_EXPORTER_OTLP_TIMEOUT",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(Duration::from_millis)
|
||||
})
|
||||
.unwrap_or(super::DEFAULT_EXPORT_TIMEOUT)
|
||||
}
|
||||
|
||||
/// A registry of started SDK spans used for best-effort ending before provider shutdown.
|
||||
///
|
||||
/// This registry belongs beside the provider in [`Initialization`]. It stores only weak references
|
||||
/// so a span that ends normally can be dropped without first unregistering itself.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ActiveSpanRegistry {
|
||||
state: Arc<Mutex<ActiveSpanRegistryState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ActiveSpanRegistryState {
|
||||
/// Prevents new spans from remaining active after shutdown begins.
|
||||
shutting_down: bool,
|
||||
/// Weak references avoid extending the lifetime of spans that end normally.
|
||||
spans: Vec<Weak<Mutex<SdkSpan>>>,
|
||||
}
|
||||
|
||||
impl ActiveSpanRegistry {
|
||||
/// Builds an SDK span, registering it before shutdown or ending it after shutdown begins.
|
||||
///
|
||||
/// `tracing-opentelemetry` calls this whenever it materializes an SDK span. If shutdown has
|
||||
/// already begun, the newly built span is ended immediately rather than being allowed to
|
||||
/// remain active.
|
||||
fn build_span(
|
||||
&self,
|
||||
tracer: &SdkTracer,
|
||||
builder: SpanBuilder,
|
||||
parent_cx: &OtelContext,
|
||||
) -> ShutdownAwareSpan {
|
||||
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
|
||||
let span = tracer.build_with_context(builder, parent_cx);
|
||||
let span_context = span.span_context().clone();
|
||||
let span = Arc::new(Mutex::new(span));
|
||||
if state.shutting_down {
|
||||
span.lock().unwrap_or_else(|err| err.into_inner()).end();
|
||||
} else {
|
||||
// Dead weak references are pruned opportunistically to avoid requiring normal span
|
||||
// completion to acquire the registry lock.
|
||||
state.spans.retain(|span| span.strong_count() > 0);
|
||||
state.spans.push(Arc::downgrade(&span));
|
||||
}
|
||||
ShutdownAwareSpan {
|
||||
span_context,
|
||||
inner: span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends every still-upgradeable registered span and then shuts down the provider.
|
||||
///
|
||||
/// The registry lock intentionally remains held through provider shutdown. This guarantees
|
||||
/// that no SDK span can be built between the final end attempt and the provider becoming unable
|
||||
/// to accept ended spans. It does not synchronize with the final drop of a span whose weak
|
||||
/// reference can no longer be upgraded, so ending previously built spans remains best-effort.
|
||||
pub(super) fn shutdown(
|
||||
&self,
|
||||
provider: &SdkTracerProvider,
|
||||
timeout: Duration,
|
||||
) -> OTelSdkResult {
|
||||
let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner());
|
||||
state.shutting_down = true;
|
||||
let spans = std::mem::take(&mut state.spans);
|
||||
|
||||
for span in spans {
|
||||
if let Some(span) = span.upgrade() {
|
||||
span.lock().unwrap_or_else(|err| err.into_inner()).end();
|
||||
}
|
||||
}
|
||||
let result = provider.shutdown_with_timeout(timeout);
|
||||
drop(state);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`SdkTracer`] adapter that routes spans through shutdown-aware construction.
|
||||
///
|
||||
/// Wrapping the tracer, rather than the span processor, is necessary because processors receive
|
||||
/// only a temporary mutable reference in `on_start` and receive owned exportable data only after
|
||||
/// `on_end`. A processor therefore cannot retain handles to, or end, active spans during shutdown.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ShutdownAwareTracer {
|
||||
inner: SdkTracer,
|
||||
active_spans: ActiveSpanRegistry,
|
||||
}
|
||||
|
||||
impl ShutdownAwareTracer {
|
||||
fn new(inner: SdkTracer, active_spans: ActiveSpanRegistry) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
active_spans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl opentelemetry::trace::Tracer for ShutdownAwareTracer {
|
||||
type Span = ShutdownAwareSpan;
|
||||
|
||||
fn build_with_context(&self, builder: SpanBuilder, parent_cx: &OtelContext) -> Self::Span {
|
||||
self.active_spans
|
||||
.build_span(&self.inner, builder, parent_cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synchronized wrapper around an SDK span shared with [`ActiveSpanRegistry`].
|
||||
///
|
||||
/// The immutable [`SpanContext`] is cached outside the mutex because the OpenTelemetry
|
||||
/// [`opentelemetry::trace::Span`] trait must return it by reference. All mutable SDK-span operations
|
||||
/// are forwarded through the mutex, allowing shutdown to end the same underlying span. Repeated
|
||||
/// end calls are harmless because SDK spans export only once.
|
||||
#[derive(Debug)]
|
||||
struct ShutdownAwareSpan {
|
||||
span_context: SpanContext,
|
||||
inner: Arc<Mutex<SdkSpan>>,
|
||||
}
|
||||
|
||||
impl opentelemetry::trace::Span for ShutdownAwareSpan {
|
||||
fn add_event_with_timestamp<T>(
|
||||
&mut self,
|
||||
name: T,
|
||||
timestamp: SystemTime,
|
||||
attributes: Vec<KeyValue>,
|
||||
) where
|
||||
T: Into<Cow<'static, str>>,
|
||||
{
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.add_event_with_timestamp(name, timestamp, attributes);
|
||||
}
|
||||
|
||||
fn span_context(&self) -> &SpanContext {
|
||||
&self.span_context
|
||||
}
|
||||
|
||||
fn is_recording(&self) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.is_recording()
|
||||
}
|
||||
|
||||
fn set_attribute(&mut self, attribute: KeyValue) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.set_attribute(attribute);
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: Status) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.set_status(status);
|
||||
}
|
||||
|
||||
fn update_name<T>(&mut self, new_name: T)
|
||||
where
|
||||
T: Into<Cow<'static, str>>,
|
||||
{
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.update_name(new_name);
|
||||
}
|
||||
|
||||
fn add_link(&mut self, span_context: SpanContext, attributes: Vec<KeyValue>) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.add_link(span_context, attributes);
|
||||
}
|
||||
|
||||
fn end_with_timestamp(&mut self, timestamp: SystemTime) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
.end_with_timestamp(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// An exporter that restricts the shared tracing subscriber's output to explicitly marked
|
||||
/// cloud-agent spans.
|
||||
///
|
||||
/// Filtering here preserves normal parent/context propagation inside the application while
|
||||
/// ensuring unrelated application tracing is never sent to the configured cloud-agent endpoint.
|
||||
/// The marker is a per-span routing attribute rather than an inherited property, so every span
|
||||
/// intended for export must set it explicitly.
|
||||
struct CloudAgentSpanExporter {
|
||||
inner: opentelemetry_otlp::SpanExporter,
|
||||
diagnostics: RateLimitedDiagnostics,
|
||||
}
|
||||
impl std::fmt::Debug for CloudAgentSpanExporter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CloudAgentSpanExporter")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpanExporter for CloudAgentSpanExporter {
|
||||
fn export(
|
||||
&self,
|
||||
batch: Vec<SpanData>,
|
||||
) -> impl std::future::Future<Output = OTelSdkResult> + Send {
|
||||
let batch: Vec<_> = batch
|
||||
.into_iter()
|
||||
.filter_map(filter_cloud_agent_span)
|
||||
.collect();
|
||||
|
||||
async move {
|
||||
if batch.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = self.inner.export(batch).await;
|
||||
if result.is_err() {
|
||||
self.diagnostics.warn_export_failure();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
|
||||
let result = self.inner.shutdown_with_timeout(timeout);
|
||||
if let Err(err) = &result {
|
||||
log::warn!("Failed to shut down the cloud-agent OpenTelemetry span exporter: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn force_flush(&self) -> OTelSdkResult {
|
||||
let result = self.inner.force_flush();
|
||||
if let Err(err) = &result {
|
||||
log::warn!("Failed to flush the cloud-agent OpenTelemetry span exporter: {err}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn set_resource(&mut self, resource: &Resource) {
|
||||
self.inner.set_resource(resource);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limits local token-free export diagnostics independently of exporter retries.
|
||||
#[derive(Debug, Default)]
|
||||
struct RateLimitedDiagnostics {
|
||||
last_export_failure: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimitedDiagnostics {
|
||||
/// Emits at most one local export-failure warning per configured interval.
|
||||
fn warn_export_failure(&self) {
|
||||
let now = Instant::now();
|
||||
let mut last_failure = self
|
||||
.last_export_failure
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if last_failure.is_none_or(|last| now.duration_since(last) >= EXPORT_FAILURE_LOG_INTERVAL) {
|
||||
*last_failure = Some(now);
|
||||
log::warn!("Failed to export cloud-agent OpenTelemetry spans");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes unrelated spans and strips the internal routing marker from spans and events before
|
||||
/// export.
|
||||
fn filter_cloud_agent_span(mut span: SpanData) -> Option<SpanData> {
|
||||
let is_cloud_agent_span = span.attributes.iter().any(|attribute| {
|
||||
attribute.key.as_str() == CLOUD_AGENT_MARKER && attribute.value == Value::Bool(true)
|
||||
});
|
||||
if !is_cloud_agent_span {
|
||||
return None;
|
||||
}
|
||||
|
||||
span.attributes
|
||||
.retain(|attribute| attribute.key.as_str() != CLOUD_AGENT_MARKER);
|
||||
for event in &mut span.events.events {
|
||||
event
|
||||
.attributes
|
||||
.retain(|attribute| attribute.key.as_str() != CLOUD_AGENT_MARKER);
|
||||
}
|
||||
Some(span)
|
||||
}
|
||||
+3
-3
@@ -405,7 +405,7 @@ impl UriHost {
|
||||
primary_window_id,
|
||||
"root_view:open_settings_page_in_existing_window",
|
||||
"root_view:open_settings_page_in_new_window",
|
||||
&SettingsSection::CloudEnvironments,
|
||||
&SettingsSection::About,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -1631,8 +1631,8 @@ fn dispatch_action_in_new_or_existing_window<T: 'static>(
|
||||
|
||||
fn settings_section_for_simple_subpage(subpage: &str) -> Option<SettingsSection> {
|
||||
match subpage {
|
||||
"billing_and_usage" => Some(SettingsSection::BillingAndUsage),
|
||||
"platform" => Some(SettingsSection::OzCloudAPIKeys),
|
||||
"billing_and_usage" => Some(SettingsSection::About),
|
||||
"platform" => Some(SettingsSection::About),
|
||||
"appearance" => Some(SettingsSection::Appearance),
|
||||
"warp_agent" => Some(SettingsSection::WarpAgent),
|
||||
_ => None,
|
||||
|
||||
@@ -1501,7 +1501,7 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings_account_page",
|
||||
"Open Settings: Account",
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::Account),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
@@ -1526,7 +1526,7 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
"workspace:show_settings_shared_blocks_page",
|
||||
BindingDescription::new("Open Settings: Shared Blocks")
|
||||
.with_custom_description(bindings::MAC_MENUS_CONTEXT, "View Shared Blocks..."),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::SharedBlocks),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
@@ -1583,13 +1583,6 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
.with_enabled(|| FeatureFlag::AgentMode.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings_billing_and_usage_page",
|
||||
BindingDescription::new("Open Settings: Billing and usage"),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::BillingAndUsage),
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings_code_page",
|
||||
BindingDescription::new("Open Settings: Code"),
|
||||
@@ -1600,14 +1593,14 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings_referrals_page",
|
||||
BindingDescription::new("Open Settings: Referrals"),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::Referrals),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings_environments_page",
|
||||
BindingDescription::new("Open Settings: Environments"),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::CloudEnvironments),
|
||||
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
|
||||
+7
-202
@@ -291,7 +291,7 @@ use crate::pane_group::pane::ActionOrigin;
|
||||
use crate::pane_group::FilePane;
|
||||
use crate::pane_group::{
|
||||
self, AIFactPane, AnyPaneContent, ChildAgentOrigin, CodeDiffPane, CodePane, CodeReviewPanelArg,
|
||||
CustomRouterEditorPane, Direction as PaneGroupDirection, Direction, EnvironmentManagementPane,
|
||||
CustomRouterEditorPane, Direction as PaneGroupDirection, Direction,
|
||||
ExecutionProfileEditorPane, NetworkLogPane, NewTerminalOptions, PaneGroup, PaneId, PanesLayout,
|
||||
TabBarHoverIndex, TerminalPaneId,
|
||||
};
|
||||
@@ -344,10 +344,6 @@ use crate::settings::{
|
||||
MonospaceFontSize, PaneSettings, PrivacySettings, SelectionSettings, Settings, SshSettings,
|
||||
ThemeSettings,
|
||||
};
|
||||
use crate::settings_view::environments_page::EnvironmentsPage;
|
||||
use crate::settings_view::handoff_environment_creation_modal::{
|
||||
HandoffEnvironmentCreationModal, HandoffEnvironmentCreationModalEvent,
|
||||
};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
|
||||
use crate::settings_view::pane_manager::SettingsPaneManager;
|
||||
@@ -1174,7 +1170,6 @@ pub struct Workspace {
|
||||
tab_config_action_sidecar_item: Option<SidecarItemKind>,
|
||||
tab_config_action_sidecar_mouse_states: crate::tab_configs::action_sidecar::SidecarMouseStates,
|
||||
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
|
||||
handoff_environment_creation_modal: Option<ViewHandle<HandoffEnvironmentCreationModal>>,
|
||||
/// Workspace-level modal hosting `AuthSecretFtuxView` for the
|
||||
/// orchestration cards' "New API key…" flow. Cloud mode renders the
|
||||
/// FTUX view inline and does not use this.
|
||||
@@ -3470,7 +3465,6 @@ impl Workspace {
|
||||
tab_config_action_sidecar_mouse_states: Default::default(),
|
||||
remove_tab_config_confirmation_dialog:
|
||||
Self::build_remove_tab_config_confirmation_dialog(ctx),
|
||||
handoff_environment_creation_modal: None,
|
||||
create_auth_secret_modal: None,
|
||||
};
|
||||
|
||||
@@ -8746,27 +8740,6 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Environment Management pane in a split pane (default direction is right).
|
||||
pub fn open_environment_management_pane(
|
||||
&mut self,
|
||||
direction: Option<Direction>,
|
||||
mode: EnvironmentsPage,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let direction = direction.unwrap_or(Direction::Right);
|
||||
let environments_page_view = self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
|
||||
let pane = EnvironmentManagementPane::new(ctx);
|
||||
let view = pane.environments_page_view(ctx);
|
||||
pane_group
|
||||
.add_pane_with_direction(direction, pane, true /* focus_new_pane */, ctx);
|
||||
view
|
||||
});
|
||||
// Update page after the pane is added so focus works correctly
|
||||
environments_page_view.update(ctx, |view, ctx| {
|
||||
view.update_page(mode, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn active_session_view(
|
||||
&self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
@@ -9599,7 +9572,7 @@ impl Workspace {
|
||||
items.push(
|
||||
MenuItemFields::new("Billing and usage")
|
||||
.with_on_select_action(WorkspaceAction::ShowSettingsPage(
|
||||
SettingsSection::BillingAndUsage,
|
||||
SettingsSection::About,
|
||||
))
|
||||
.into_item(),
|
||||
);
|
||||
@@ -14959,83 +14932,6 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn show_handoff_environment_creation_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Capture the initiating source view now, before async creation begins.
|
||||
// If we waited until the Created callback, the user may have switched panes.
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
let source_view = self
|
||||
.active_tab_pane_group()
|
||||
.as_ref(ctx)
|
||||
.active_session_view(ctx);
|
||||
|
||||
let modal = ctx.add_typed_action_view(HandoffEnvironmentCreationModal::new);
|
||||
ctx.subscribe_to_view(&modal, move |me, _, event, ctx| match event {
|
||||
HandoffEnvironmentCreationModalEvent::Created { env_id } => {
|
||||
let env_id = *env_id;
|
||||
me.handoff_environment_creation_modal = None;
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
{
|
||||
if let Some(source_view) = source_view.as_ref() {
|
||||
let (launch, entry_point) = source_view.update(ctx, |view, ctx| {
|
||||
let input = view.input().clone();
|
||||
input.update(ctx, |input, ctx| {
|
||||
let prompt = input
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.buffer_text(ctx)
|
||||
.trim()
|
||||
.to_owned();
|
||||
let attachments = input.collect_cloud_launch_attachments(ctx);
|
||||
let entry_point = input.handoff_entry_point(ctx);
|
||||
input.exit_cloud_handoff_compose_and_clear(ctx);
|
||||
let launch = if prompt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PendingCloudLaunch {
|
||||
prompt,
|
||||
attachments,
|
||||
})
|
||||
};
|
||||
(launch, entry_point)
|
||||
})
|
||||
});
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::OpenLocalToCloudHandoffPane {
|
||||
launch,
|
||||
environment_id: Some(env_id),
|
||||
entry_point,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
|
||||
{
|
||||
let _ = env_id;
|
||||
}
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::Cancelled => {
|
||||
me.handoff_environment_creation_modal = None;
|
||||
me.focus_active_tab(ctx);
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::CreationFailed { error_message } => {
|
||||
me.handoff_environment_creation_modal = None;
|
||||
me.toast_stack.update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(format!(
|
||||
"Failed to create environment: {error_message}"
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
me.focus_active_tab(ctx);
|
||||
}
|
||||
});
|
||||
modal.update(ctx, |modal, ctx| modal.show(ctx));
|
||||
ctx.focus(&modal);
|
||||
self.handoff_environment_creation_modal = Some(modal);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Opens the workspace-level blocking modal for creating a new managed
|
||||
/// auth secret. Persists the new secret on success and dismisses the
|
||||
/// modal; cards adopt it via `HarnessAvailabilityEvent::AuthSecretCreated`.
|
||||
@@ -15089,81 +14985,6 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn show_cloud_mode_v2_environment_creation_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(source_view) = self
|
||||
.active_tab_pane_group()
|
||||
.as_ref(ctx)
|
||||
.active_session_view(ctx)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let modal = ctx.add_typed_action_view(HandoffEnvironmentCreationModal::new);
|
||||
ctx.subscribe_to_view(&modal, move |me, _, event, ctx| match event {
|
||||
HandoffEnvironmentCreationModalEvent::Created { env_id } => {
|
||||
let env_id = *env_id;
|
||||
me.handoff_environment_creation_modal = None;
|
||||
let Some(model_handle) =
|
||||
source_view.as_ref(ctx).ambient_agent_view_model().cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pending = source_view.update(ctx, |view, ctx| {
|
||||
let input = view.input().clone();
|
||||
input.update(ctx, |input, ctx| {
|
||||
let prompt = input
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.buffer_text(ctx)
|
||||
.trim()
|
||||
.to_owned();
|
||||
if prompt.is_empty() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
let attachments = input
|
||||
.collect_cloud_launch_attachments(ctx)
|
||||
.request_attachments;
|
||||
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
|
||||
let attachments = Vec::new();
|
||||
input.editor().update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
input.ai_context_model().update(ctx, |model, ctx| {
|
||||
model.clear_pending_attachments(ctx);
|
||||
});
|
||||
Some((prompt, attachments))
|
||||
})
|
||||
});
|
||||
model_handle.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_id), ctx);
|
||||
if let Some((prompt, attachments)) = pending {
|
||||
model.spawn_agent(prompt, attachments, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::Cancelled => {
|
||||
me.handoff_environment_creation_modal = None;
|
||||
me.focus_active_tab(ctx);
|
||||
}
|
||||
HandoffEnvironmentCreationModalEvent::CreationFailed { error_message } => {
|
||||
me.handoff_environment_creation_modal = None;
|
||||
me.toast_stack.update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(format!(
|
||||
"Failed to create environment: {error_message}"
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
me.focus_active_tab(ctx);
|
||||
}
|
||||
});
|
||||
modal.update(ctx, |modal, ctx| modal.show(ctx));
|
||||
ctx.focus(&modal);
|
||||
self.handoff_environment_creation_modal = Some(modal);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
fn restore_source_handoff_draft(
|
||||
source_view: &ViewHandle<TerminalView>,
|
||||
@@ -16895,13 +16716,7 @@ impl Workspace {
|
||||
pane_group::Event::OpenAgentProfileEditor { profile_id } => {
|
||||
self.open_execution_profile_editor_pane(None, *profile_id, ctx);
|
||||
}
|
||||
pane_group::Event::OpenEnvironmentManagementPane => {
|
||||
self.open_environment_management_pane(
|
||||
None,
|
||||
crate::settings_view::environments_page::EnvironmentsPage::Create,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
pane_group::Event::OpenEnvironmentManagementPane => {}
|
||||
pane_group::Event::OpenLspLogs { log_path } => {
|
||||
self.open_lsp_logs(log_path, ctx);
|
||||
}
|
||||
@@ -23872,12 +23687,8 @@ impl TypedActionView for Workspace {
|
||||
let _ = (terminal_view_id, conversation_id, trigger);
|
||||
}
|
||||
}
|
||||
ShowHandoffEnvironmentCreationModal => {
|
||||
self.show_handoff_environment_creation_modal(ctx);
|
||||
}
|
||||
ShowCloudModeV2EnvironmentCreationModal => {
|
||||
self.show_cloud_mode_v2_environment_creation_modal(ctx);
|
||||
}
|
||||
ShowHandoffEnvironmentCreationModal => {}
|
||||
ShowCloudModeV2EnvironmentCreationModal => {}
|
||||
OpenCreateAuthSecretModal { harness } => {
|
||||
self.show_create_auth_secret_modal(*harness, ctx);
|
||||
}
|
||||
@@ -23994,7 +23805,7 @@ impl TypedActionView for Workspace {
|
||||
ctx.open_url(&upgrade_url);
|
||||
}
|
||||
ShowReferralSettingsPage => {
|
||||
self.show_settings_with_section(Some(SettingsSection::Referrals), ctx);
|
||||
self.show_settings_with_section(Some(SettingsSection::About), ctx);
|
||||
}
|
||||
JoinSlack => self.join_slack(ctx),
|
||||
ViewUserDocs => self.view_user_docs(ctx),
|
||||
@@ -24946,9 +24757,7 @@ impl TypedActionView for Workspace {
|
||||
ctx
|
||||
);
|
||||
}
|
||||
OpenEnvironmentManagementPane => {
|
||||
self.open_environment_management_pane(None, EnvironmentsPage::Create, ctx);
|
||||
}
|
||||
OpenEnvironmentManagementPane => {}
|
||||
ToggleAIDocumentPane {
|
||||
document_id,
|
||||
document_version,
|
||||
@@ -26986,10 +26795,6 @@ impl View for Workspace {
|
||||
stack.add_child(ChildView::new(lightbox_view).finish());
|
||||
}
|
||||
|
||||
if let Some(handoff_modal) = &self.handoff_environment_creation_modal {
|
||||
stack.add_child(ChildView::new(handoff_modal).finish());
|
||||
}
|
||||
|
||||
if let Some(create_auth_secret_modal) = &self.create_auth_secret_modal {
|
||||
stack.add_child(ChildView::new(create_auth_secret_modal).finish());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user