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:
Generated
+784
-814
File diff suppressed because it is too large
Load Diff
+1
-11
@@ -217,15 +217,6 @@ notify-debouncer-full = { git = "https://github.com/warpdotdev/notify", rev = "9
|
|||||||
nom = "7.1.1"
|
nom = "7.1.1"
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
oauth2 = { version = "5.0.0", default-features = false }
|
oauth2 = { version = "5.0.0", default-features = false }
|
||||||
opentelemetry = { version = "0.32.0", default-features = false, features = ["trace"] }
|
|
||||||
opentelemetry-http = "0.32.0"
|
|
||||||
opentelemetry-otlp = { version = "0.32.0", default-features = false, features = [
|
|
||||||
"gzip-http",
|
|
||||||
"http-proto",
|
|
||||||
"trace",
|
|
||||||
"zstd-http",
|
|
||||||
] }
|
|
||||||
opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["trace"] }
|
|
||||||
openh264 = "0.8"
|
openh264 = "0.8"
|
||||||
static_assertions = "1.1.0"
|
static_assertions = "1.1.0"
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
@@ -306,7 +297,6 @@ tower = "0.5.2"
|
|||||||
tower-http = "0.6.6"
|
tower-http = "0.6.6"
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-futures = "0.2.5"
|
tracing-futures = "0.2.5"
|
||||||
tracing-opentelemetry = { version = "0.33.0", default-features = false }
|
|
||||||
tracing-subscriber = { version = "0.3.22", default-features = false, features = [
|
tracing-subscriber = { version = "0.3.22", default-features = false, features = [
|
||||||
"registry",
|
"registry",
|
||||||
"std",
|
"std",
|
||||||
@@ -453,7 +443,7 @@ split-debuginfo = "unpacked"
|
|||||||
|
|
||||||
[profile.dev.package]
|
[profile.dev.package]
|
||||||
# Improve the performance of core terminal logic.
|
# Improve the performance of core terminal logic.
|
||||||
warp_terminal.opt-level = 3
|
galaxy_terminal.opt-level = 3
|
||||||
# Minimize the runtime overhead of CPU profiling at the cost of a small
|
# Minimize the runtime overhead of CPU profiling at the cost of a small
|
||||||
# increase in compile time for this crate.
|
# increase in compile time for this crate.
|
||||||
backtrace.opt-level = 3
|
backtrace.opt-level = 3
|
||||||
|
|||||||
@@ -321,13 +321,8 @@ http_server.workspace = true
|
|||||||
hyper.workspace = true
|
hyper.workspace = true
|
||||||
libsqlite3-sys = { version = "0.33.0", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.33.0", features = ["bundled"] }
|
||||||
mio = { version = "1.1.1", features = ["os-poll", "os-ext"] }
|
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.workspace = true
|
||||||
tokio-util.workspace = true
|
tokio-util.workspace = true
|
||||||
tracing-opentelemetry.workspace = true
|
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
# AWS SDK (loading credentials for BYO LLM)
|
# 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),
|
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),
|
_ => ("Tool completed successfully.".to_string(), false),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::ai::agent::{
|
|||||||
DocumentContext, EditDocumentsRequest, EditDocumentsResult, ReadDocumentsRequest,
|
DocumentContext, EditDocumentsRequest, EditDocumentsResult, ReadDocumentsRequest,
|
||||||
ReadDocumentsResult,
|
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::cloud_object::model::persistence::CloudModel;
|
||||||
use crate::notebooks::CloudNotebookModel;
|
use crate::notebooks::CloudNotebookModel;
|
||||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||||
@@ -59,10 +59,11 @@ impl NotebookExecutor {
|
|||||||
|
|
||||||
for document in documents {
|
for document in documents {
|
||||||
let client_id = ClientId::new();
|
let client_id = ClientId::new();
|
||||||
|
let document_id = AIDocumentId::new();
|
||||||
let model = CloudNotebookModel {
|
let model = CloudNotebookModel {
|
||||||
title: document.title.clone(),
|
title: document.title.clone(),
|
||||||
data: document.content.clone(),
|
data: document.content.clone(),
|
||||||
ai_document_id: None,
|
ai_document_id: Some(document_id),
|
||||||
conversation_id: None,
|
conversation_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ impl NotebookExecutor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
created.push(DocumentContext {
|
created.push(DocumentContext {
|
||||||
document_id: crate::ai::document::ai_document_model::AIDocumentId::new(),
|
document_id,
|
||||||
document_version: AIDocumentVersion::default(),
|
document_version: AIDocumentVersion::default(),
|
||||||
content: document.content.clone(),
|
content: document.content.clone(),
|
||||||
line_ranges: vec![],
|
line_ranges: vec![],
|
||||||
@@ -112,10 +113,15 @@ impl NotebookExecutor {
|
|||||||
let mut documents = Vec::new();
|
let mut documents = Vec::new();
|
||||||
|
|
||||||
for id in document_ids {
|
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
|
let notebook = cloud_model
|
||||||
.get_all_active_notebooks()
|
.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 {
|
if let Some(notebook) = notebook {
|
||||||
documents.push(DocumentContext {
|
documents.push(DocumentContext {
|
||||||
@@ -149,9 +155,15 @@ impl NotebookExecutor {
|
|||||||
|
|
||||||
for diff in diffs {
|
for diff in diffs {
|
||||||
let cloud_model = CloudModel::as_ref(ctx);
|
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
|
let notebook_data = cloud_model
|
||||||
|
.get_all_active_notebooks()
|
||||||
|
.find(|nb| nb.model().ai_document_id.as_ref() == Some(&diff.document_id))
|
||||||
|
.or_else(|| {
|
||||||
|
cloud_model
|
||||||
.get_all_active_notebooks()
|
.get_all_active_notebooks()
|
||||||
.find(|nb| nb.id.uid() == diff.document_id.to_string())
|
.find(|nb| nb.id.uid() == diff.document_id.to_string())
|
||||||
|
})
|
||||||
.map(|nb| (nb.id, nb.model().data.clone()));
|
.map(|nb| (nb.id, nb.model().data.clone()));
|
||||||
|
|
||||||
let Some((notebook_id, current_data)) = notebook_data else {
|
let Some((notebook_id, current_data)) = notebook_data else {
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
use warpui::elements::{ChildView, Element, Empty};
|
use warpui::elements::{Element, Empty};
|
||||||
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
|
use warpui::{AppContext, Entity, TypedActionView, View, ViewContext};
|
||||||
|
|
||||||
use crate::settings_view::handoff_environment_creation_modal::{
|
|
||||||
HandoffEnvironmentCreationModal, HandoffEnvironmentCreationModalEvent,
|
|
||||||
};
|
|
||||||
use crate::view_components::DismissibleToast;
|
|
||||||
use crate::workspace::ToastStack;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum CreateEnvironmentModalEvent {
|
pub enum CreateEnvironmentModalEvent {
|
||||||
@@ -15,38 +9,11 @@ pub enum CreateEnvironmentModalEvent {
|
|||||||
|
|
||||||
pub struct CreateEnvironmentModal {
|
pub struct CreateEnvironmentModal {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
handoff_modal: ViewHandle<HandoffEnvironmentCreationModal>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateEnvironmentModal {
|
impl CreateEnvironmentModal {
|
||||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||||
let handoff_modal =
|
Self { visible: false }
|
||||||
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 is_visible(&self) -> bool {
|
pub fn is_visible(&self) -> bool {
|
||||||
@@ -55,10 +22,7 @@ impl CreateEnvironmentModal {
|
|||||||
|
|
||||||
pub fn show(&mut self, ctx: &mut ViewContext<Self>) {
|
pub fn show(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
self.visible = true;
|
self.visible = true;
|
||||||
self.handoff_modal.update(ctx, |modal, ctx| {
|
ctx.emit(CreateEnvironmentModalEvent::Cancelled);
|
||||||
modal.show(ctx);
|
|
||||||
});
|
|
||||||
ctx.focus(&self.handoff_modal);
|
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +30,6 @@ impl CreateEnvironmentModal {
|
|||||||
self.visible = false;
|
self.visible = false;
|
||||||
ctx.notify();
|
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 {
|
impl Entity for CreateEnvironmentModal {
|
||||||
@@ -96,11 +48,7 @@ impl View for CreateEnvironmentModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||||
if !self.visible {
|
Empty::new().finish()
|
||||||
return Empty::new().finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
ChildView::new(&self.handoff_modal).finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1476,9 +1476,8 @@ impl View for RequestedCommandView {
|
|||||||
let is_input_pinned_to_top =
|
let is_input_pinned_to_top =
|
||||||
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
|
*InputModeSettings::as_ref(app).input_mode.value() == InputMode::PinnedToTop;
|
||||||
|
|
||||||
// When expanded details are rendered using a regular block, having a non-zero horizontal
|
// Tracks whether the expanded command has a terminal output block directly below it.
|
||||||
// margin while toggled expanded will cause the body to look wider than the header.
|
// Used to remove the bottom margin for visual continuity with the terminal block.
|
||||||
// The expanded details should also appear connected to the header, so we remove bottom margin in this case.
|
|
||||||
let is_rendered_above_expanded_command_block = {
|
let is_rendered_above_expanded_command_block = {
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
|
|
||||||
@@ -1517,7 +1516,6 @@ impl View for RequestedCommandView {
|
|||||||
let header_element = self.render_header(
|
let header_element = self.render_header(
|
||||||
!should_render_editor
|
!should_render_editor
|
||||||
&& !should_render_mcp_content
|
&& !should_render_mcp_content
|
||||||
&& !is_rendered_above_expanded_command_block
|
|
||||||
&& !has_citations_footer,
|
&& !has_citations_footer,
|
||||||
app,
|
app,
|
||||||
);
|
);
|
||||||
@@ -1613,12 +1611,9 @@ impl View for RequestedCommandView {
|
|||||||
theme.surface_2()
|
theme.surface_2()
|
||||||
};
|
};
|
||||||
|
|
||||||
// If the requested command state is completed and input isn't pinned to the top, we're
|
// If the requested command is expanded above a terminal block or
|
||||||
// going to have a regular block directly below this one with the output of the executed
|
// the next exchange flows directly after, remove bottom margin for
|
||||||
// command. Since we can't control the top padding of the AI block that comes _after_ the
|
// visual continuity.
|
||||||
// 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.
|
|
||||||
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|
let should_remove_bottom_margin = is_rendered_above_expanded_command_block
|
||||||
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
|
|| ((self.action_type.is_requested_command() || self.action_type.is_mcp_tool())
|
||||||
&& is_last_output_message_in_output
|
&& is_last_output_message_in_output
|
||||||
@@ -1645,28 +1640,20 @@ impl View for RequestedCommandView {
|
|||||||
&& !is_input_pinned_to_top);
|
&& !is_input_pinned_to_top);
|
||||||
|
|
||||||
let container = Container::new(content.finish())
|
let container = Container::new(content.finish())
|
||||||
.with_margin_left(if is_rendered_above_expanded_command_block {
|
.with_margin_left(
|
||||||
0.
|
if action_status.is_some_and(|status| status.is_blocked()) {
|
||||||
} else if action_status.is_some_and(|status| status.is_blocked()) {
|
|
||||||
CONTENT_HORIZONTAL_PADDING
|
CONTENT_HORIZONTAL_PADDING
|
||||||
} else {
|
} else {
|
||||||
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
|
CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.
|
||||||
})
|
},
|
||||||
.with_margin_right(if is_rendered_above_expanded_command_block {
|
)
|
||||||
0.
|
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
|
||||||
} else {
|
|
||||||
CONTENT_HORIZONTAL_PADDING
|
|
||||||
})
|
|
||||||
.with_margin_bottom(if should_remove_bottom_margin {
|
.with_margin_bottom(if should_remove_bottom_margin {
|
||||||
0.
|
0.
|
||||||
} else {
|
} else {
|
||||||
CONTENT_ITEM_VERTICAL_MARGIN
|
CONTENT_ITEM_VERTICAL_MARGIN
|
||||||
})
|
})
|
||||||
.with_corner_radius(if is_rendered_above_expanded_command_block {
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||||
CornerRadius::with_top(Radius::Pixels(8.))
|
|
||||||
} else {
|
|
||||||
CornerRadius::with_all(Radius::Pixels(8.))
|
|
||||||
})
|
|
||||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ impl View for PromptAlertView {
|
|||||||
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
text_fragments.push(FormattedTextFragment::plain_text(" "));
|
||||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||||
"Add credits",
|
"Add credits",
|
||||||
WorkspaceAction::ShowSettingsPage(SettingsSection::BillingAndUsage),
|
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
self.action_hyperlink(&state, &mut text_fragments, app);
|
self.action_hyperlink(&state, &mut text_fragments, app);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use crate::code::editor_management::CodeSource;
|
|||||||
use crate::drive::OpenGalaxyDriveObjectSettings;
|
use crate::drive::OpenGalaxyDriveObjectSettings;
|
||||||
use crate::root_view::quake_mode_window_id;
|
use crate::root_view::quake_mode_window_id;
|
||||||
use crate::server::ids::SyncId;
|
use crate::server::ids::SyncId;
|
||||||
use crate::settings_view::environments_page::EnvironmentsPage;
|
|
||||||
use crate::settings_view::SettingsSection;
|
use crate::settings_view::SettingsSection;
|
||||||
use crate::tab::SelectedTabColor;
|
use crate::tab::SelectedTabColor;
|
||||||
use crate::terminal::ShellLaunchData;
|
use crate::terminal::ShellLaunchData;
|
||||||
@@ -282,9 +281,7 @@ pub enum EnvVarCollectionPaneSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct EnvironmentManagementPaneSnapshot {
|
pub struct EnvironmentManagementPaneSnapshot;
|
||||||
pub mode: EnvironmentsPage,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum SettingsPaneSnapshot {
|
pub enum SettingsPaneSnapshot {
|
||||||
|
|||||||
@@ -1804,8 +1804,10 @@ impl NotebooksEditorModel {
|
|||||||
diffs: Vec<ai::diff_validation::DiffDelta>,
|
diffs: Vec<ai::diff_validation::DiffDelta>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
// Get current markdown content
|
// Get current markdown content using the unescaped form so that line
|
||||||
let current_markdown = self.markdown(ctx);
|
// 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 lines: Vec<&str> = current_markdown.lines().collect();
|
||||||
let mut result_lines = Vec::new();
|
let mut result_lines = Vec::new();
|
||||||
|
|||||||
@@ -8047,15 +8047,7 @@ impl View for PaneGroup {
|
|||||||
.environment_setup_mode_selector_handle()
|
.environment_setup_mode_selector_handle()
|
||||||
.cloned()
|
.cloned()
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| None);
|
||||||
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()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if let Some(handle) = selector_handle {
|
if let Some(handle) = selector_handle {
|
||||||
stack.add_child(ChildView::new(&handle).finish());
|
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.
|
// 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(_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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.finish()
|
stack.finish()
|
||||||
|
|||||||
@@ -2,48 +2,26 @@ use galaxyui::{AppContext, ModelHandle, ViewContext, ViewHandle};
|
|||||||
|
|
||||||
use super::view::PaneView;
|
use super::view::PaneView;
|
||||||
use super::{
|
use super::{
|
||||||
DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneGroup, PaneId, ShareableLink,
|
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||||
ShareableLinkError,
|
ShareableLinkError,
|
||||||
};
|
};
|
||||||
use crate::ai::ambient_agents::github_auth_url::GithubAuthRedirectTarget;
|
|
||||||
use crate::app_state::{EnvironmentManagementPaneSnapshot, LeafContents};
|
use crate::app_state::{EnvironmentManagementPaneSnapshot, LeafContents};
|
||||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||||
use crate::settings_view::environments_page::{EnvironmentsPage, EnvironmentsPageView};
|
use crate::settings_view::{SettingsView, SettingsViewEvent};
|
||||||
use crate::settings_view::settings_page::{PaneEventWrapper, SettingsPageEvent};
|
|
||||||
|
|
||||||
pub struct EnvironmentManagementPane {
|
pub struct EnvironmentManagementPane {
|
||||||
view: ViewHandle<PaneView<EnvironmentsPageView>>,
|
view: ViewHandle<PaneView<SettingsView>>,
|
||||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnvironmentManagementPane {
|
impl EnvironmentManagementPane {
|
||||||
pub fn new(ctx: &mut ViewContext<PaneGroup>) -> Self {
|
pub fn new(ctx: &mut ViewContext<PaneGroup>) -> Self {
|
||||||
// Create the EnvironmentsPageView
|
let settings_view = ctx.add_typed_action_view(|ctx| SettingsView::new(None, ctx));
|
||||||
let environments_page_view = ctx.add_typed_action_view(|ctx| {
|
let pane_configuration = settings_view.as_ref(ctx).pane_configuration();
|
||||||
let mut view = EnvironmentsPageView::new(ctx);
|
|
||||||
view.set_github_auth_redirect_target(GithubAuthRedirectTarget::FocusCloudMode, ctx);
|
|
||||||
view
|
|
||||||
});
|
|
||||||
|
|
||||||
Self::from_view(environments_page_view, ctx)
|
let view = ctx.add_typed_action_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 pane_id = PaneId::from_environment_management_pane_ctx(ctx);
|
let pane_id = PaneId::from_environment_management_pane_ctx(ctx);
|
||||||
PaneView::new(
|
PaneView::new(pane_id, settings_view, (), pane_configuration.clone(), ctx)
|
||||||
pane_id,
|
|
||||||
environments_page_view,
|
|
||||||
(),
|
|
||||||
pane_configuration.clone(),
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -51,18 +29,6 @@ impl EnvironmentManagementPane {
|
|||||||
pane_configuration,
|
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 {
|
impl PaneContent for EnvironmentManagementPane {
|
||||||
@@ -78,34 +44,7 @@ impl PaneContent for EnvironmentManagementPane {
|
|||||||
) {
|
) {
|
||||||
self.view
|
self.view
|
||||||
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
|
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
|
||||||
|
|
||||||
let pane_id = self.id();
|
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| {
|
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
|
||||||
group.handle_pane_view_event(pane_id, event, ctx);
|
group.handle_pane_view_event(pane_id, event, ctx);
|
||||||
});
|
});
|
||||||
@@ -117,15 +56,11 @@ impl PaneContent for EnvironmentManagementPane {
|
|||||||
_detach_type: DetachType,
|
_detach_type: DetachType,
|
||||||
ctx: &mut ViewContext<PaneGroup>,
|
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);
|
ctx.unsubscribe_to_view(&self.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&self, ctx: &AppContext) -> LeafContents {
|
fn snapshot(&self, _ctx: &AppContext) -> LeafContents {
|
||||||
LeafContents::EnvironmentManagement(EnvironmentManagementPaneSnapshot {
|
LeafContents::EnvironmentManagement(EnvironmentManagementPaneSnapshot)
|
||||||
mode: self.current_mode(ctx),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
|
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>) {
|
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
|
||||||
self.environments_page_view(ctx)
|
ctx.focus(&self.view);
|
||||||
.update(ctx, |view, ctx| view.focus(ctx));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shareable_link(
|
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::network_log_view::NetworkLogView;
|
||||||
use crate::server::telemetry::SharingDialogSource;
|
use crate::server::telemetry::SharingDialogSource;
|
||||||
use crate::settings::PaneSettings;
|
use crate::settings::PaneSettings;
|
||||||
use crate::settings_view::environments_page::EnvironmentsPageView;
|
|
||||||
use crate::settings_view::SettingsView;
|
use crate::settings_view::SettingsView;
|
||||||
use crate::terminal::available_shells::AvailableShell;
|
use crate::terminal::available_shells::AvailableShell;
|
||||||
use crate::terminal::TerminalView;
|
use crate::terminal::TerminalView;
|
||||||
@@ -214,9 +213,9 @@ impl PaneId {
|
|||||||
Self::new_from_ctx(IPaneType::EnvVarCollection, ctx)
|
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(
|
pub fn from_environment_management_pane_ctx(
|
||||||
ctx: &ViewContext<PaneView<EnvironmentsPageView>>,
|
ctx: &ViewContext<PaneView<SettingsView>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new_from_ctx(IPaneType::EnvironmentManagement, ctx)
|
Self::new_from_ctx(IPaneType::EnvironmentManagement, ctx)
|
||||||
}
|
}
|
||||||
@@ -312,9 +311,9 @@ impl PaneId {
|
|||||||
Self::new(IPaneType::EnvVarCollection, env_var_collection_view)
|
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(
|
pub fn from_environment_management_pane_view(
|
||||||
environment_management_pane_view: &ViewHandle<PaneView<EnvironmentsPageView>>,
|
environment_management_pane_view: &ViewHandle<PaneView<SettingsView>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
IPaneType::EnvironmentManagement,
|
IPaneType::EnvironmentManagement,
|
||||||
@@ -469,7 +468,7 @@ impl PaneId {
|
|||||||
ChildView::<PaneView<EnvVarCollectionView>>::with_id(self.0.pane_view_id).finish()
|
ChildView::<PaneView<EnvVarCollectionView>>::with_id(self.0.pane_view_id).finish()
|
||||||
}
|
}
|
||||||
IPaneType::EnvironmentManagement => {
|
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 => {
|
IPaneType::Workflow => {
|
||||||
ChildView::<PaneView<WorkflowView>>::with_id(self.0.pane_view_id).finish()
|
ChildView::<PaneView<WorkflowView>>::with_id(self.0.pane_view_id).finish()
|
||||||
|
|||||||
@@ -1264,8 +1264,6 @@ impl ServerApiProvider {
|
|||||||
ServerExperiments::handle(ctx).update(ctx, |state, ctx| {
|
ServerExperiments::handle(ctx).update(ctx, |state, ctx| {
|
||||||
state.apply_latest_state(experiments, ctx);
|
state.apply_latest_state(experiments, ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
settings_view::handle_experiment_change(ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructs a new SeverApiProvider for tests.
|
/// Constructs a new SeverApiProvider for tests.
|
||||||
|
|||||||
@@ -35,13 +35,7 @@ use regex::Regex;
|
|||||||
use settings::{Setting, ToggleableSetting};
|
use settings::{Setting, ToggleableSetting};
|
||||||
use strum::IntoEnumIterator;
|
use strum::IntoEnumIterator;
|
||||||
|
|
||||||
use super::custom_inference_modal::{
|
|
||||||
CustomEndpointModal, CustomEndpointModalEvent, CustomEndpointModalViewState,
|
|
||||||
};
|
|
||||||
use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent};
|
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::set_default_model_modal::{SetDefaultModelModalBody, SetDefaultModelModalBodyEvent};
|
||||||
use super::settings_page::{
|
use super::settings_page::{
|
||||||
build_sub_header, build_toggle_element, render_body_item_label,
|
build_sub_header, build_toggle_element, render_body_item_label,
|
||||||
@@ -708,13 +702,6 @@ pub struct AISettingsPageView {
|
|||||||
#[cfg(feature = "local_fs")]
|
#[cfg(feature = "local_fs")]
|
||||||
add_router_button: ViewHandle<ActionButton>,
|
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
|
// 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.
|
// custom endpoint is saved while the default isn't backed by a credential.
|
||||||
set_default_model_modal: ModalViewState<Modal<SetDefaultModelModalBody>>,
|
set_default_model_modal: ModalViewState<Modal<SetDefaultModelModalBody>>,
|
||||||
@@ -765,7 +752,6 @@ impl AISettingsPageView {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
me.sync_custom_endpoint_buttons(ctx);
|
|
||||||
ctx.notify();
|
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.
|
// 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_base_model_menu(&me.base_model_dropdown, ctx);
|
||||||
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
||||||
me.sync_custom_endpoint_buttons(ctx);
|
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1146,7 +1131,6 @@ impl AISettingsPageView {
|
|||||||
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
|
Self::refresh_base_model_menu(&me.base_model_dropdown, ctx);
|
||||||
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
Self::refresh_coding_model_menu(&me.coding_model_dropdown, ctx);
|
||||||
me.sync_context_window_editor(ctx, false);
|
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
|
// Driving the prompt off the key-store update (rather than the editor's
|
||||||
// blur/Enter) means it fires reliably however the key was committed —
|
// blur/Enter) means it fires reliably however the key was committed —
|
||||||
// clicking outside the field, pressing Enter, or tabbing away.
|
// 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_allowlist_dropdown(&me.mcp_allowlist_dropdown, ctx);
|
||||||
Self::refresh_mcp_denylist_dropdown(&me.mcp_denylist_dropdown, ctx);
|
Self::refresh_mcp_denylist_dropdown(&me.mcp_denylist_dropdown, ctx);
|
||||||
me.sync_context_window_editor(ctx, true);
|
me.sync_context_window_editor(ctx, true);
|
||||||
me.sync_custom_endpoint_buttons(ctx);
|
|
||||||
}
|
}
|
||||||
AISettingsChangedEvent::VoiceInputEnabled { .. } => {
|
AISettingsChangedEvent::VoiceInputEnabled { .. } => {
|
||||||
me.update_voice_input_dropdown_enablement(ctx);
|
me.update_voice_input_dropdown_enablement(ctx);
|
||||||
@@ -1719,68 +1702,6 @@ impl AISettingsPageView {
|
|||||||
button.set_disabled(!is_any_ai_enabled, ctx);
|
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);
|
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| {
|
ctx.subscribe_to_view(&set_default_model_modal_body, |me, _, event, ctx| {
|
||||||
me.handle_set_default_model_modal_event(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 set_default_model_modal = ModalViewState::new(set_default_model_modal_view);
|
||||||
let last_seen_provider_keys = ApiKeyManager::as_ref(ctx).keys().clone();
|
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| {
|
let agent_toolbar_inline_editor = ctx.add_typed_action_view(|ctx| {
|
||||||
AgentToolbarInlineEditor::new(AgentToolbarEditorMode::AgentView, ctx)
|
AgentToolbarInlineEditor::new(AgentToolbarEditorMode::AgentView, ctx)
|
||||||
});
|
});
|
||||||
@@ -1959,11 +1865,6 @@ impl AISettingsPageView {
|
|||||||
router_views,
|
router_views,
|
||||||
#[cfg(feature = "local_fs")]
|
#[cfg(feature = "local_fs")]
|
||||||
add_router_button,
|
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,
|
set_default_model_modal,
|
||||||
last_seen_provider_keys,
|
last_seen_provider_keys,
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
@@ -1986,17 +1887,9 @@ impl AISettingsPageView {
|
|||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_modal_content(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
pub fn get_modal_content(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
|
||||||
if self.custom_endpoint_modal_state.is_open() {
|
if self.set_default_model_modal.is_open() {
|
||||||
Some(self.custom_endpoint_modal_state.render())
|
|
||||||
} else if self.set_default_model_modal.is_open() {
|
|
||||||
Some(self.set_default_model_modal.render())
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -2174,336 +2067,6 @@ impl AISettingsPageView {
|
|||||||
self.show_set_default_model_modal(description, choices, ctx);
|
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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn create_grok_code_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
|
fn create_grok_code_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
|
||||||
ctx.add_typed_action_view(|ctx| {
|
ctx.add_typed_action_view(|ctx| {
|
||||||
@@ -3635,9 +3198,6 @@ pub enum AISettingsPageAction {
|
|||||||
#[cfg(feature = "local_fs")]
|
#[cfg(feature = "local_fs")]
|
||||||
OpenAddCustomRouter,
|
OpenAddCustomRouter,
|
||||||
|
|
||||||
// Custom inference
|
|
||||||
OpenAddCustomEndpointModal,
|
|
||||||
OpenEditCustomEndpointModal(usize),
|
|
||||||
ConnectGrokSubscription,
|
ConnectGrokSubscription,
|
||||||
DisconnectGrokSubscription,
|
DisconnectGrokSubscription,
|
||||||
|
|
||||||
@@ -4440,12 +4000,6 @@ impl TypedActionView for AISettingsPageView {
|
|||||||
AISettingsPageAction::OpenAddCustomRouter => {
|
AISettingsPageAction::OpenAddCustomRouter => {
|
||||||
ctx.emit(AISettingsPageEvent::OpenCustomRouterEditor(None));
|
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 => {
|
AISettingsPageAction::ToggleCloudHandoff => {
|
||||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||||
report_if_error!(settings
|
report_if_error!(settings
|
||||||
@@ -8528,11 +8082,7 @@ impl ApiKeysWidget {
|
|||||||
.with_child(chips)
|
.with_child(chips)
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
let edit_button = view
|
let edit_button = Empty::new().finish();
|
||||||
.custom_endpoint_edit_buttons
|
|
||||||
.get(index)
|
|
||||||
.map(|button| button.as_ref(app).render(app))
|
|
||||||
.unwrap_or_else(|| Empty::new().finish());
|
|
||||||
|
|
||||||
let row = Flex::row()
|
let row = Flex::row()
|
||||||
.with_main_axis_size(MainAxisSize::Max)
|
.with_main_axis_size(MainAxisSize::Max)
|
||||||
@@ -8782,7 +8332,6 @@ impl SettingsWidget for ApiKeysWidget {
|
|||||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
.with_child(header_left)
|
.with_child(header_left)
|
||||||
.with_child(view.custom_inference_add_button.as_ref(app).render(app))
|
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
column.add_child(
|
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 about_page::AboutPageView;
|
||||||
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
|
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
|
||||||
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
|
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
|
||||||
use billing_and_usage_dispatch::BillingAndUsageDispatchView;
|
|
||||||
use billing_and_usage_page::BillingAndUsagePageEvent;
|
|
||||||
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
|
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
|
||||||
use environments_page::EnvironmentsPageView;
|
|
||||||
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
|
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
|
||||||
use galaxy_core::channel::ChannelState;
|
use galaxy_core::channel::ChannelState;
|
||||||
use galaxy_core::context_flag::ContextFlag;
|
use galaxy_core::context_flag::ContextFlag;
|
||||||
@@ -32,19 +29,16 @@ use galaxyui::{
|
|||||||
};
|
};
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
use keybindings::KeybindingsView;
|
use keybindings::KeybindingsView;
|
||||||
use main_page::{MainPageAction, MainSettingsPageEvent, MainSettingsPageView};
|
|
||||||
use mcp_servers_page::MCPServersSettingsPageView;
|
use mcp_servers_page::MCPServersSettingsPageView;
|
||||||
use nav::{SettingsNavItem, SettingsUmbrella};
|
use nav::{SettingsNavItem, SettingsUmbrella};
|
||||||
use pathfinder_geometry::vector::Vector2F;
|
use pathfinder_geometry::vector::Vector2F;
|
||||||
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
|
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
|
||||||
use referrals_page::{ReferralsPageEvent, ReferralsPageView};
|
|
||||||
use scripting_page::ScriptingSettingsPageView;
|
use scripting_page::ScriptingSettingsPageView;
|
||||||
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
|
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
|
||||||
use settings_page::{
|
use settings_page::{
|
||||||
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
|
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
|
||||||
HEADER_PADDING,
|
HEADER_PADDING,
|
||||||
};
|
};
|
||||||
use show_blocks_view::{ShowBlocksEvent, ShowBlocksView};
|
|
||||||
use teams_page::{TeamsPageView, TeamsPageViewEvent};
|
use teams_page::{TeamsPageView, TeamsPageViewEvent};
|
||||||
use warpify_page::{WarpifyPageAction, WarpifyPageView};
|
use warpify_page::{WarpifyPageAction, WarpifyPageView};
|
||||||
|
|
||||||
@@ -77,22 +71,13 @@ mod admin_actions;
|
|||||||
mod agent_assisted_environment_modal;
|
mod agent_assisted_environment_modal;
|
||||||
mod ai_page;
|
mod ai_page;
|
||||||
mod appearance_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;
|
mod code_page;
|
||||||
pub(crate) mod custom_inference_modal;
|
|
||||||
mod custom_router_view;
|
mod custom_router_view;
|
||||||
mod delete_environment_confirmation_dialog;
|
|
||||||
mod directory_color_add_picker;
|
mod directory_color_add_picker;
|
||||||
pub(crate) mod environments_page;
|
|
||||||
mod execution_profile_view;
|
mod execution_profile_view;
|
||||||
mod features;
|
mod features;
|
||||||
mod features_page;
|
mod features_page;
|
||||||
pub(crate) mod handoff_environment_creation_modal;
|
|
||||||
pub mod keybindings;
|
pub mod keybindings;
|
||||||
mod main_page;
|
|
||||||
pub mod mcp_servers;
|
pub mod mcp_servers;
|
||||||
pub mod mcp_servers_page;
|
pub mod mcp_servers_page;
|
||||||
mod nav;
|
mod nav;
|
||||||
@@ -101,17 +86,13 @@ mod platform;
|
|||||||
mod platform_page;
|
mod platform_page;
|
||||||
mod privacy;
|
mod privacy;
|
||||||
mod privacy_page;
|
mod privacy_page;
|
||||||
mod referrals_page;
|
|
||||||
mod remove_custom_endpoint_confirmation_dialog;
|
|
||||||
mod scripting_page;
|
mod scripting_page;
|
||||||
mod set_default_model_modal;
|
mod set_default_model_modal;
|
||||||
mod settings_file_footer;
|
mod settings_file_footer;
|
||||||
pub(crate) mod settings_page;
|
pub(crate) mod settings_page;
|
||||||
mod show_blocks_view;
|
|
||||||
mod tab_menu;
|
mod tab_menu;
|
||||||
mod teams_page;
|
mod teams_page;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
mod transfer_ownership_confirmation_modal;
|
|
||||||
pub mod update_environment_form;
|
pub mod update_environment_form;
|
||||||
mod warp_drive_page;
|
mod warp_drive_page;
|
||||||
mod warpify_page;
|
mod warpify_page;
|
||||||
@@ -119,10 +100,8 @@ mod warpify_page;
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub(crate) use ai_page::cli_agent_settings_widget_id;
|
pub(crate) use ai_page::cli_agent_settings_widget_id;
|
||||||
pub(crate) use ai_page::custom_model_routers_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 code_page::CodeSettingsPageView;
|
||||||
pub use features_page::FeaturesPageAction;
|
pub use features_page::FeaturesPageAction;
|
||||||
pub use main_page::handle_experiment_change;
|
|
||||||
pub use privacy_page::PrivacyPageAction;
|
pub use privacy_page::PrivacyPageAction;
|
||||||
pub use settings_page::{
|
pub use settings_page::{
|
||||||
render_body_item_label, render_info_icon, render_input_list, render_separator, AdditionalInfo,
|
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)]
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
pub enum SettingsSection {
|
pub enum SettingsSection {
|
||||||
About,
|
About,
|
||||||
Account,
|
|
||||||
MCPServers,
|
MCPServers,
|
||||||
BillingAndUsage,
|
|
||||||
#[default]
|
#[default]
|
||||||
Appearance,
|
Appearance,
|
||||||
Features,
|
Features,
|
||||||
Keybindings,
|
Keybindings,
|
||||||
Privacy,
|
Privacy,
|
||||||
Referrals,
|
|
||||||
Scripting,
|
Scripting,
|
||||||
SharedBlocks,
|
|
||||||
Teams,
|
Teams,
|
||||||
WarpDrive,
|
WarpDrive,
|
||||||
Warpify,
|
Warpify,
|
||||||
@@ -280,11 +255,6 @@ pub enum SettingsSection {
|
|||||||
// ── Code umbrella subpages ──
|
// ── Code umbrella subpages ──
|
||||||
CodeIndexing,
|
CodeIndexing,
|
||||||
EditorAndCodeReview,
|
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};
|
use std::fmt::{self, Display};
|
||||||
@@ -294,7 +264,6 @@ use crate::util::bindings::custom_tag_to_keystroke;
|
|||||||
impl Display for SettingsSection {
|
impl Display for SettingsSection {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
SettingsSection::BillingAndUsage => write!(f, "Billing and usage"),
|
|
||||||
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
|
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
|
||||||
SettingsSection::MCPServers => write!(f, "MCP Servers"),
|
SettingsSection::MCPServers => write!(f, "MCP Servers"),
|
||||||
SettingsSection::Scripting => write!(f, "Scripting"),
|
SettingsSection::Scripting => write!(f, "Scripting"),
|
||||||
@@ -378,22 +347,17 @@ impl FromStr for SettingsSection {
|
|||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s {
|
||||||
"About" => Ok(Self::About),
|
"About" => Ok(Self::About),
|
||||||
"Account" => Ok(Self::Account),
|
|
||||||
"AI" => Ok(Self::AI),
|
"AI" => Ok(Self::AI),
|
||||||
"MCP Servers" => Ok(Self::MCPServers),
|
"MCP Servers" => Ok(Self::MCPServers),
|
||||||
"Billing and usage" => Ok(Self::BillingAndUsage),
|
|
||||||
"Appearance" => Ok(Self::Appearance),
|
"Appearance" => Ok(Self::Appearance),
|
||||||
"Code" => Ok(Self::Code),
|
"Code" => Ok(Self::Code),
|
||||||
"Features" => Ok(Self::Features),
|
"Features" => Ok(Self::Features),
|
||||||
"Keyboard shortcuts" => Ok(Self::Keybindings),
|
"Keyboard shortcuts" => Ok(Self::Keybindings),
|
||||||
"Privacy" => Ok(Self::Privacy),
|
"Privacy" => Ok(Self::Privacy),
|
||||||
"Referrals" => Ok(Self::Referrals),
|
|
||||||
"Scripting" => Ok(Self::Scripting),
|
"Scripting" => Ok(Self::Scripting),
|
||||||
"Shared blocks" => Ok(Self::SharedBlocks),
|
|
||||||
"Teams" => Ok(Self::Teams),
|
"Teams" => Ok(Self::Teams),
|
||||||
"Warpify" => Ok(Self::Warpify),
|
"Warpify" => Ok(Self::Warpify),
|
||||||
"WarpDrive" | "Galaxy Drive" => Ok(Self::WarpDrive),
|
"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),
|
"Oz" | "Warp Agent" | "Galaxy Agent" => Ok(Self::WarpAgent),
|
||||||
"Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles),
|
"Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles),
|
||||||
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
|
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
|
||||||
@@ -402,9 +366,6 @@ impl FromStr for SettingsSection {
|
|||||||
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
|
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
|
||||||
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
||||||
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
||||||
"CloudEnvironments" | "Environments" => Ok(Self::CloudEnvironments),
|
|
||||||
"OzCloudAPIKeys" => Ok(Self::OzCloudAPIKeys),
|
|
||||||
"SharedBlocks" | "Shared Blocks" => Ok(Self::SharedBlocks),
|
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,7 +597,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
|||||||
context: &ContextPredicate,
|
context: &ContextPredicate,
|
||||||
builder: fn(SettingsAction) -> T,
|
builder: fn(SettingsAction) -> T,
|
||||||
) {
|
) {
|
||||||
main_page::init_actions_from_parent_view(app, context, builder);
|
|
||||||
appearance_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);
|
features_page::init_actions_from_parent_view(app, context, builder);
|
||||||
warpify_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 {
|
pub enum SettingsAction {
|
||||||
SelectAndRefresh(SettingsSection),
|
SelectAndRefresh(SettingsSection),
|
||||||
ToggleUmbrella(usize),
|
ToggleUmbrella(usize),
|
||||||
MainPageToggle(MainPageAction),
|
|
||||||
AppearancePageToggle(AppearancePageAction),
|
AppearancePageToggle(AppearancePageAction),
|
||||||
FeaturesPageToggle(FeaturesPageAction),
|
FeaturesPageToggle(FeaturesPageAction),
|
||||||
PrivacyPageToggle(PrivacyPageAction),
|
PrivacyPageToggle(PrivacyPageAction),
|
||||||
@@ -1088,24 +1047,19 @@ fn next_stop_index(current: usize, len: usize, direction: CycleDirection) -> usi
|
|||||||
macro_rules! update_page {
|
macro_rules! update_page {
|
||||||
($handle:expr, $update:expr, $ctx:expr) => {
|
($handle:expr, $update:expr, $ctx:expr) => {
|
||||||
match $handle {
|
match $handle {
|
||||||
SettingsPageViewHandle::Main(handle) => $ctx.update_view(handle, $update),
|
|
||||||
SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::Privacy(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::Scripting(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
|
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::Code(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::MCPServers(handle) => $ctx.update_view(handle, $update),
|
||||||
SettingsPageViewHandle::WarpDrive(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::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 pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("Settings"));
|
||||||
|
|
||||||
let global_resource_handles = GlobalResourceHandlesProvider::as_ref(ctx).get().clone();
|
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
|
// Appearance & themes page
|
||||||
let appearance_page_handle = ctx.add_typed_action_view(AppearanceSettingsPageView::new);
|
let appearance_page_handle = ctx.add_typed_action_view(AppearanceSettingsPageView::new);
|
||||||
@@ -1178,13 +1127,6 @@ impl SettingsView {
|
|||||||
me.handle_ai_page_event(event, ctx);
|
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
|
// Keybindings page
|
||||||
let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new);
|
let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new);
|
||||||
|
|
||||||
@@ -1259,9 +1201,7 @@ impl SettingsView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut settings_pages = vec![
|
let mut settings_pages = vec![
|
||||||
SettingsPage::new(main_page_handle),
|
|
||||||
SettingsPage::new(ai_page_handle),
|
SettingsPage::new(ai_page_handle),
|
||||||
billing_and_usage_page,
|
|
||||||
SettingsPage::new(code_page_handle),
|
SettingsPage::new(code_page_handle),
|
||||||
SettingsPage::new(appearance_page_handle),
|
SettingsPage::new(appearance_page_handle),
|
||||||
SettingsPage::new(features_page_handle),
|
SettingsPage::new(features_page_handle),
|
||||||
@@ -1305,16 +1245,7 @@ impl SettingsView {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if FeatureFlag::WarpControlCli.is_enabled() {
|
if FeatureFlag::WarpControlCli.is_enabled() {
|
||||||
let shared_blocks_index = nav_items
|
nav_items.push(SettingsNavItem::Page(SettingsSection::Scripting));
|
||||||
.iter()
|
|
||||||
.position(|item| {
|
|
||||||
matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks))
|
|
||||||
})
|
|
||||||
.unwrap_or(nav_items.len());
|
|
||||||
nav_items.insert(
|
|
||||||
shared_blocks_index,
|
|
||||||
SettingsNavItem::Page(SettingsSection::Scripting),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the initial page: map internal backing-page sections to their default subpage.
|
// 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::AI) => SettingsSection::WarpAgent,
|
||||||
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
|
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
|
||||||
Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => {
|
Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => {
|
||||||
SettingsSection::Account
|
SettingsSection::About
|
||||||
}
|
}
|
||||||
Some(section) if section.is_subpage() => section,
|
Some(section) if section.is_subpage() => section,
|
||||||
other => other.unwrap_or_default(),
|
other => other.unwrap_or_default(),
|
||||||
@@ -1686,40 +1617,6 @@ impl SettingsView {
|
|||||||
.collect();
|
.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(
|
fn handle_appearance_page_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: &SettingsPageEvent,
|
event: &SettingsPageEvent,
|
||||||
@@ -2021,20 +1918,18 @@ impl SettingsView {
|
|||||||
|
|
||||||
fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool {
|
fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool {
|
||||||
match &settings_page.view_handle {
|
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::Keybindings(v) => v.as_ref(app).should_render(app),
|
||||||
SettingsPageViewHandle::Features(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::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::About(v) => v.as_ref(app).should_render(app),
|
||||||
SettingsPageViewHandle::Privacy(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::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::Scripting(v) => v.as_ref(app).should_render(app),
|
||||||
SettingsPageViewHandle::AI(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::MCPServers(v) => v.as_ref(app).should_render(app),
|
||||||
SettingsPageViewHandle::Code(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::WarpDrive(v) => v.as_ref(app).should_render(app),
|
||||||
|
SettingsPageViewHandle::Platform(v) => v.as_ref(app).should_render(app),
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2222,9 +2117,6 @@ impl SettingsView {
|
|||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
) -> Option<Box<dyn Element>> {
|
) -> Option<Box<dyn Element>> {
|
||||||
match page_handle {
|
match page_handle {
|
||||||
SettingsPageViewHandle::BillingAndUsage(view) => {
|
|
||||||
view.read(app, |view, _| view.get_modal_content(app))
|
|
||||||
}
|
|
||||||
SettingsPageViewHandle::Privacy(view) => {
|
SettingsPageViewHandle::Privacy(view) => {
|
||||||
view.read(app, |view, _| view.get_modal_content())
|
view.read(app, |view, _| view.get_modal_content())
|
||||||
}
|
}
|
||||||
@@ -2560,15 +2452,6 @@ impl TypedActionView for SettingsView {
|
|||||||
ctx.notify();
|
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) => {
|
SettingsAction::AppearancePageToggle(appearance_action) => {
|
||||||
if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) {
|
if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) {
|
||||||
if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle {
|
if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle {
|
||||||
|
|||||||
@@ -910,7 +910,7 @@ impl PlatformPageWidget {
|
|||||||
|
|
||||||
impl SettingsPageMeta for PlatformPageView {
|
impl SettingsPageMeta for PlatformPageView {
|
||||||
fn section() -> SettingsSection {
|
fn section() -> SettingsSection {
|
||||||
SettingsSection::OzCloudAPIKeys
|
SettingsSection::About
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_render(&self, ctx: &AppContext) -> bool {
|
fn should_render(&self, ctx: &AppContext) -> bool {
|
||||||
@@ -941,7 +941,7 @@ impl SettingsPageMeta for PlatformPageView {
|
|||||||
|
|
||||||
impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
|
impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
|
||||||
fn from(view_handle: ViewHandle<PlatformPageView>) -> Self {
|
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::about_page::AboutPageView;
|
||||||
use super::ai_page::{AISettingsPageAction, AISettingsPageView};
|
use super::ai_page::{AISettingsPageAction, AISettingsPageView};
|
||||||
use super::appearance_page::AppearanceSettingsPageView;
|
use super::appearance_page::AppearanceSettingsPageView;
|
||||||
use super::billing_and_usage_dispatch::BillingAndUsageDispatchView;
|
|
||||||
use super::code_page::CodeSettingsPageView;
|
use super::code_page::CodeSettingsPageView;
|
||||||
use super::environments_page::EnvironmentsPageView;
|
|
||||||
use super::features_page::FeaturesPageView;
|
use super::features_page::FeaturesPageView;
|
||||||
use super::keybindings::KeybindingsView;
|
use super::keybindings::KeybindingsView;
|
||||||
use super::main_page::MainSettingsPageView;
|
|
||||||
use super::mcp_servers_page::MCPServersSettingsPageView;
|
use super::mcp_servers_page::MCPServersSettingsPageView;
|
||||||
use super::privacy_page::PrivacyPageView;
|
use super::privacy_page::PrivacyPageView;
|
||||||
use super::referrals_page::ReferralsPageView;
|
|
||||||
use super::scripting_page::ScriptingSettingsPageView;
|
use super::scripting_page::ScriptingSettingsPageView;
|
||||||
use super::show_blocks_view::ShowBlocksView;
|
|
||||||
use super::teams_page::TeamsPageView;
|
use super::teams_page::TeamsPageView;
|
||||||
use super::warp_drive_page::WarpDriveSettingsPageView;
|
use super::warp_drive_page::WarpDriveSettingsPageView;
|
||||||
use super::warpify_page::WarpifyPageView;
|
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).
|
/// It is required to allow for SettingsPage struct be put in the collection (ie. vector).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum SettingsPageViewHandle {
|
pub enum SettingsPageViewHandle {
|
||||||
Main(ViewHandle<MainSettingsPageView>),
|
|
||||||
Appearance(ViewHandle<AppearanceSettingsPageView>),
|
Appearance(ViewHandle<AppearanceSettingsPageView>),
|
||||||
Features(ViewHandle<FeaturesPageView>),
|
Features(ViewHandle<FeaturesPageView>),
|
||||||
Keybindings(ViewHandle<KeybindingsView>),
|
Keybindings(ViewHandle<KeybindingsView>),
|
||||||
@@ -108,23 +102,18 @@ pub enum SettingsPageViewHandle {
|
|||||||
Code(ViewHandle<CodeSettingsPageView>),
|
Code(ViewHandle<CodeSettingsPageView>),
|
||||||
Privacy(ViewHandle<PrivacyPageView>),
|
Privacy(ViewHandle<PrivacyPageView>),
|
||||||
Warpify(ViewHandle<WarpifyPageView>),
|
Warpify(ViewHandle<WarpifyPageView>),
|
||||||
Referrals(ViewHandle<ReferralsPageView>),
|
|
||||||
Scripting(ViewHandle<ScriptingSettingsPageView>),
|
Scripting(ViewHandle<ScriptingSettingsPageView>),
|
||||||
AI(ViewHandle<AISettingsPageView>),
|
AI(ViewHandle<AISettingsPageView>),
|
||||||
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
|
|
||||||
BillingAndUsage(ViewHandle<BillingAndUsageDispatchView>),
|
|
||||||
MCPServers(ViewHandle<MCPServersSettingsPageView>),
|
MCPServers(ViewHandle<MCPServersSettingsPageView>),
|
||||||
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
|
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
|
||||||
OzCloudAPIKeys(ViewHandle<super::platform_page::PlatformPageView>),
|
Teams(ViewHandle<TeamsPageView>),
|
||||||
Teams(ViewHandle<super::teams_page::TeamsPageView>),
|
Platform(ViewHandle<super::platform_page::PlatformPageView>),
|
||||||
SharedBlocks(ViewHandle<ShowBlocksView>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsPageViewHandle {
|
impl SettingsPageViewHandle {
|
||||||
pub fn child_view(&self) -> Box<dyn Element> {
|
pub fn child_view(&self) -> Box<dyn Element> {
|
||||||
use SettingsPageViewHandle::*;
|
use SettingsPageViewHandle::*;
|
||||||
match self {
|
match self {
|
||||||
Main(view_handle) => ChildView::new(view_handle).finish(),
|
|
||||||
Appearance(view_handle) => ChildView::new(view_handle).finish(),
|
Appearance(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
Features(view_handle) => ChildView::new(view_handle).finish(),
|
Features(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
Keybindings(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(),
|
Code(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
Privacy(view_handle) => ChildView::new(view_handle).finish(),
|
Privacy(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
Warpify(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(),
|
Scripting(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
AI(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(),
|
MCPServers(view_handle) => ChildView::new(view_handle).finish(),
|
||||||
WarpDrive(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(),
|
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,
|
SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
|
||||||
};
|
};
|
||||||
use super::tab_menu::Tabs;
|
use super::tab_menu::Tabs;
|
||||||
use super::transfer_ownership_confirmation_modal::{
|
|
||||||
TransferOwnershipConfirmationEvent, TransferOwnershipConfirmationModal,
|
|
||||||
};
|
|
||||||
use super::SettingsSection;
|
use super::SettingsSection;
|
||||||
use crate::ai::AIRequestUsageModel;
|
use crate::ai::AIRequestUsageModel;
|
||||||
use crate::appearance::Appearance;
|
use crate::appearance::Appearance;
|
||||||
@@ -475,7 +472,6 @@ pub struct TeamsPageView {
|
|||||||
team_action_confirmation_dialog: ViewHandle<CloudActionConfirmationDialog>,
|
team_action_confirmation_dialog: ViewHandle<CloudActionConfirmationDialog>,
|
||||||
show_team_action_confirmation_dialog: bool,
|
show_team_action_confirmation_dialog: bool,
|
||||||
pending_team_action_confirmation: Option<TeamActionConfirmationTarget>,
|
pending_team_action_confirmation: Option<TeamActionConfirmationTarget>,
|
||||||
transfer_ownership_modal_state: ModalViewState<Modal<TransferOwnershipConfirmationModal>>,
|
|
||||||
clipped_scroll_state: ClippedScrollStateHandle,
|
clipped_scroll_state: ClippedScrollStateHandle,
|
||||||
discoverable_teams_states: Vec<DiscoverableTeamState>,
|
discoverable_teams_states: Vec<DiscoverableTeamState>,
|
||||||
rename_team_editor: ViewHandle<ClickableTextInput>,
|
rename_team_editor: ViewHandle<ClickableTextInput>,
|
||||||
@@ -605,18 +601,7 @@ impl TypedActionView for TeamsPageView {
|
|||||||
self.join_team_with_team_discovery(*team_uid, ctx);
|
self.join_team_with_team_discovery(*team_uid, ctx);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
TeamsPageAction::ShowTransferOwnershipModal {
|
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::ToggleTeamDiscoverabilityBeforeCreation => {
|
TeamsPageAction::ToggleTeamDiscoverabilityBeforeCreation => {
|
||||||
self.checkbox_value = !self.checkbox_value;
|
self.checkbox_value = !self.checkbox_value;
|
||||||
}
|
}
|
||||||
@@ -796,35 +781,6 @@ impl TeamsPageView {
|
|||||||
me.handle_cloud_action_confirmation_dialog_event(event, ctx);
|
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());
|
let member_actions_menu = ctx.add_typed_action_view(|_| Menu::new().with_drop_shadow());
|
||||||
ctx.subscribe_to_view(&member_actions_menu, |me, _, event, ctx| {
|
ctx.subscribe_to_view(&member_actions_menu, |me, _, event, ctx| {
|
||||||
if let menu::Event::Close { .. } = event {
|
if let menu::Event::Close { .. } = event {
|
||||||
@@ -861,7 +817,6 @@ impl TeamsPageView {
|
|||||||
team_action_confirmation_dialog,
|
team_action_confirmation_dialog,
|
||||||
show_team_action_confirmation_dialog: false,
|
show_team_action_confirmation_dialog: false,
|
||||||
pending_team_action_confirmation: None,
|
pending_team_action_confirmation: None,
|
||||||
transfer_ownership_modal_state: ModalViewState::new(transfer_ownership_modal),
|
|
||||||
discoverable_teams_states: Vec::new(),
|
discoverable_teams_states: Vec::new(),
|
||||||
rename_team_editor,
|
rename_team_editor,
|
||||||
checkbox_value: true,
|
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(
|
fn handle_approve_domains_block_editor_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: &WordBlockEditorViewEvent,
|
event: &WordBlockEditorViewEvent,
|
||||||
@@ -4396,17 +4298,6 @@ impl SettingsWidget for TeamsWidget {
|
|||||||
let mut stack = Stack::new();
|
let mut stack = Stack::new();
|
||||||
stack.add_child(Flex::column().with_child(content).finish());
|
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() {
|
if view.should_show_remove_user_from_team_confirmation_dialog() {
|
||||||
stack.add_positioned_overlay_child(
|
stack.add_positioned_overlay_child(
|
||||||
ChildView::new(&view.team_action_confirmation_dialog).finish(),
|
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 galaxy_graphql::error::BudgetExceededError;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
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,
|
Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||||
OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Shrinkable,
|
OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Shrinkable,
|
||||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
|
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
|
||||||
@@ -31,7 +31,6 @@ use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
|||||||
use crate::send_telemetry_from_ctx;
|
use crate::send_telemetry_from_ctx;
|
||||||
use crate::server::ids::ServerId;
|
use crate::server::ids::ServerId;
|
||||||
use crate::server::telemetry::{OutOfCreditsBannerAction, TelemetryEvent};
|
use crate::server::telemetry::{OutOfCreditsBannerAction, TelemetryEvent};
|
||||||
use crate::settings_view::create_discount_badge;
|
|
||||||
use crate::view_components::{Dropdown, DropdownAction};
|
use crate::view_components::{Dropdown, DropdownAction};
|
||||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||||
|
|
||||||
@@ -356,7 +355,7 @@ impl BuyCreditsBanner {
|
|||||||
.with_color(text_color.into())
|
.with_color(text_color.into())
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
let discount_badge = Empty::new().finish();
|
||||||
|
|
||||||
Flex::row()
|
Flex::row()
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
.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::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||||
use crate::send_telemetry_from_ctx;
|
use crate::send_telemetry_from_ctx;
|
||||||
use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent};
|
use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent};
|
||||||
use crate::settings_view::create_discount_badge;
|
|
||||||
use crate::ui_components::blended_colors;
|
use crate::ui_components::blended_colors;
|
||||||
use crate::view_components::{Dropdown, DropdownAction, ToastFlavor};
|
use crate::view_components::{Dropdown, DropdownAction, ToastFlavor};
|
||||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||||
@@ -182,7 +181,7 @@ impl EnableAutoReloadModalBody {
|
|||||||
.with_color(text_color.into())
|
.with_color(text_color.into())
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
let discount_badge = Empty::new().finish();
|
||||||
|
|
||||||
Flex::row()
|
Flex::row()
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
|
|||||||
@@ -3563,7 +3563,7 @@ impl Input {
|
|||||||
let buy_credits_banner = ctx.add_typed_action_view(BuyCreditsBanner::new);
|
let buy_credits_banner = ctx.add_typed_action_view(BuyCreditsBanner::new);
|
||||||
ctx.subscribe_to_view(&buy_credits_banner, |me, _, event, ctx| match event {
|
ctx.subscribe_to_view(&buy_credits_banner, |me, _, event, ctx| match event {
|
||||||
BuyCreditsBannerEvent::OpenBillingAndUsage => {
|
BuyCreditsBannerEvent::OpenBillingAndUsage => {
|
||||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||||
}
|
}
|
||||||
BuyCreditsBannerEvent::RefocusInput => {
|
BuyCreditsBannerEvent::RefocusInput => {
|
||||||
ctx.focus(&me.editor);
|
ctx.focus(&me.editor);
|
||||||
@@ -6397,7 +6397,7 @@ impl Input {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
PromptAlertEvent::OpenBillingAndUsagePage => {
|
PromptAlertEvent::OpenBillingAndUsagePage => {
|
||||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||||
}
|
}
|
||||||
PromptAlertEvent::OpenBillingPortal { team_uid } => {
|
PromptAlertEvent::OpenBillingPortal { team_uid } => {
|
||||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||||
@@ -15685,7 +15685,7 @@ impl Input {
|
|||||||
entrypoint: AnonymousUserSignupEntrypoint::SignUpAIPrompt,
|
entrypoint: AnonymousUserSignupEntrypoint::SignUpAIPrompt,
|
||||||
}),
|
}),
|
||||||
PromptSuggestionsEvent::OpenBillingAndUsagePage => {
|
PromptSuggestionsEvent::OpenBillingAndUsagePage => {
|
||||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage))
|
ctx.emit(Event::OpenSettings(SettingsSection::About))
|
||||||
}
|
}
|
||||||
PromptSuggestionsEvent::OpenBillingPortal { team_uid } => {
|
PromptSuggestionsEvent::OpenBillingPortal { team_uid } => {
|
||||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ impl ShareBlockModal {
|
|||||||
.on_click(|ctx, _, _| {
|
.on_click(|ctx, _, _| {
|
||||||
ctx.dispatch_typed_action(ShareBlockModalAction::Close);
|
ctx.dispatch_typed_action(ShareBlockModalAction::Close);
|
||||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPage(
|
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPage(
|
||||||
SettingsSection::SharedBlocks,
|
SettingsSection::About,
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
if matches!(self.request_state, ShareRequestState::Pending(_)) {
|
if matches!(self.request_state, ShareRequestState::Pending(_)) {
|
||||||
|
|||||||
@@ -27165,7 +27165,7 @@ impl TypedActionView for TerminalView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
OpenBillingAndUsagePane => {
|
OpenBillingAndUsagePane => {
|
||||||
ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage));
|
ctx.emit(Event::OpenSettings(SettingsSection::About));
|
||||||
}
|
}
|
||||||
OpenAddRulePane => {
|
OpenAddRulePane => {
|
||||||
ctx.emit(Event::OpenAddRulePane);
|
ctx.emit(Event::OpenAddRulePane);
|
||||||
|
|||||||
+6
-78
@@ -1,102 +1,30 @@
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tracing::subscriber;
|
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> {
|
pub fn init() -> anyhow::Result<Initialization> {
|
||||||
#[cfg(target_family = "wasm")]
|
subscriber::set_global_default(subscriber::NoSubscriber::new())?;
|
||||||
{
|
|
||||||
install_no_subscriber()?;
|
|
||||||
Ok(Initialization::default())
|
Ok(Initialization::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
native::init()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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(
|
pub fn start_auth_refresh(
|
||||||
client: std::sync::Arc<dyn warp_managed_secrets::client::ManagedSecretsClient>,
|
_client: std::sync::Arc<dyn warp_managed_secrets::client::ManagedSecretsClient>,
|
||||||
ctx: &mut warpui::AppContext,
|
_ctx: &mut warpui::AppContext,
|
||||||
) {
|
) {
|
||||||
native::start_auth_refresh(client, ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_no_subscriber() -> anyhow::Result<()> {
|
#[derive(Default)]
|
||||||
// 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))]
|
|
||||||
pub struct Initialization {
|
pub struct Initialization {
|
||||||
initialization_warning: Option<anyhow::Error>,
|
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 {
|
impl Initialization {
|
||||||
pub fn log_initialization_warning(&mut self) {
|
pub fn log_initialization_warning(&mut self) {
|
||||||
if let Some(err) = self.initialization_warning.take() {
|
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) {
|
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) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Initialization {
|
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,
|
primary_window_id,
|
||||||
"root_view:open_settings_page_in_existing_window",
|
"root_view:open_settings_page_in_existing_window",
|
||||||
"root_view:open_settings_page_in_new_window",
|
"root_view:open_settings_page_in_new_window",
|
||||||
&SettingsSection::CloudEnvironments,
|
&SettingsSection::About,
|
||||||
ctx,
|
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> {
|
fn settings_section_for_simple_subpage(subpage: &str) -> Option<SettingsSection> {
|
||||||
match subpage {
|
match subpage {
|
||||||
"billing_and_usage" => Some(SettingsSection::BillingAndUsage),
|
"billing_and_usage" => Some(SettingsSection::About),
|
||||||
"platform" => Some(SettingsSection::OzCloudAPIKeys),
|
"platform" => Some(SettingsSection::About),
|
||||||
"appearance" => Some(SettingsSection::Appearance),
|
"appearance" => Some(SettingsSection::Appearance),
|
||||||
"warp_agent" => Some(SettingsSection::WarpAgent),
|
"warp_agent" => Some(SettingsSection::WarpAgent),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
@@ -1501,7 +1501,7 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
|||||||
EditableBinding::new(
|
EditableBinding::new(
|
||||||
"workspace:show_settings_account_page",
|
"workspace:show_settings_account_page",
|
||||||
"Open Settings: Account",
|
"Open Settings: Account",
|
||||||
WorkspaceAction::ShowSettingsPage(SettingsSection::Account),
|
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||||
)
|
)
|
||||||
.with_context_predicate(id!("Workspace"))
|
.with_context_predicate(id!("Workspace"))
|
||||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
.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",
|
"workspace:show_settings_shared_blocks_page",
|
||||||
BindingDescription::new("Open Settings: Shared Blocks")
|
BindingDescription::new("Open Settings: Shared Blocks")
|
||||||
.with_custom_description(bindings::MAC_MENUS_CONTEXT, "View 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_group(bindings::BindingGroup::Settings.as_str())
|
||||||
.with_context_predicate(id!("Workspace"))
|
.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_enabled(|| FeatureFlag::AgentMode.is_enabled())
|
||||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||||
.with_context_predicate(id!("Workspace")),
|
.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(
|
EditableBinding::new(
|
||||||
"workspace:show_settings_code_page",
|
"workspace:show_settings_code_page",
|
||||||
BindingDescription::new("Open Settings: Code"),
|
BindingDescription::new("Open Settings: Code"),
|
||||||
@@ -1600,14 +1593,14 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
|||||||
EditableBinding::new(
|
EditableBinding::new(
|
||||||
"workspace:show_settings_referrals_page",
|
"workspace:show_settings_referrals_page",
|
||||||
BindingDescription::new("Open Settings: Referrals"),
|
BindingDescription::new("Open Settings: Referrals"),
|
||||||
WorkspaceAction::ShowSettingsPage(SettingsSection::Referrals),
|
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||||
)
|
)
|
||||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||||
.with_context_predicate(id!("Workspace")),
|
.with_context_predicate(id!("Workspace")),
|
||||||
EditableBinding::new(
|
EditableBinding::new(
|
||||||
"workspace:show_settings_environments_page",
|
"workspace:show_settings_environments_page",
|
||||||
BindingDescription::new("Open Settings: Environments"),
|
BindingDescription::new("Open Settings: Environments"),
|
||||||
WorkspaceAction::ShowSettingsPage(SettingsSection::CloudEnvironments),
|
WorkspaceAction::ShowSettingsPage(SettingsSection::About),
|
||||||
)
|
)
|
||||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||||
.with_context_predicate(id!("Workspace")),
|
.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::FilePane;
|
||||||
use crate::pane_group::{
|
use crate::pane_group::{
|
||||||
self, AIFactPane, AnyPaneContent, ChildAgentOrigin, CodeDiffPane, CodePane, CodeReviewPanelArg,
|
self, AIFactPane, AnyPaneContent, ChildAgentOrigin, CodeDiffPane, CodePane, CodeReviewPanelArg,
|
||||||
CustomRouterEditorPane, Direction as PaneGroupDirection, Direction, EnvironmentManagementPane,
|
CustomRouterEditorPane, Direction as PaneGroupDirection, Direction,
|
||||||
ExecutionProfileEditorPane, NetworkLogPane, NewTerminalOptions, PaneGroup, PaneId, PanesLayout,
|
ExecutionProfileEditorPane, NetworkLogPane, NewTerminalOptions, PaneGroup, PaneId, PanesLayout,
|
||||||
TabBarHoverIndex, TerminalPaneId,
|
TabBarHoverIndex, TerminalPaneId,
|
||||||
};
|
};
|
||||||
@@ -344,10 +344,6 @@ use crate::settings::{
|
|||||||
MonospaceFontSize, PaneSettings, PrivacySettings, SelectionSettings, Settings, SshSettings,
|
MonospaceFontSize, PaneSettings, PrivacySettings, SelectionSettings, Settings, SshSettings,
|
||||||
ThemeSettings,
|
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::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||||
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
|
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
|
||||||
use crate::settings_view::pane_manager::SettingsPaneManager;
|
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_item: Option<SidecarItemKind>,
|
||||||
tab_config_action_sidecar_mouse_states: crate::tab_configs::action_sidecar::SidecarMouseStates,
|
tab_config_action_sidecar_mouse_states: crate::tab_configs::action_sidecar::SidecarMouseStates,
|
||||||
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
|
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
|
||||||
handoff_environment_creation_modal: Option<ViewHandle<HandoffEnvironmentCreationModal>>,
|
|
||||||
/// Workspace-level modal hosting `AuthSecretFtuxView` for the
|
/// Workspace-level modal hosting `AuthSecretFtuxView` for the
|
||||||
/// orchestration cards' "New API key…" flow. Cloud mode renders the
|
/// orchestration cards' "New API key…" flow. Cloud mode renders the
|
||||||
/// FTUX view inline and does not use this.
|
/// FTUX view inline and does not use this.
|
||||||
@@ -3470,7 +3465,6 @@ impl Workspace {
|
|||||||
tab_config_action_sidecar_mouse_states: Default::default(),
|
tab_config_action_sidecar_mouse_states: Default::default(),
|
||||||
remove_tab_config_confirmation_dialog:
|
remove_tab_config_confirmation_dialog:
|
||||||
Self::build_remove_tab_config_confirmation_dialog(ctx),
|
Self::build_remove_tab_config_confirmation_dialog(ctx),
|
||||||
handoff_environment_creation_modal: None,
|
|
||||||
create_auth_secret_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(
|
pub(super) fn active_session_view(
|
||||||
&self,
|
&self,
|
||||||
ctx: &mut ViewContext<Self>,
|
ctx: &mut ViewContext<Self>,
|
||||||
@@ -9599,7 +9572,7 @@ impl Workspace {
|
|||||||
items.push(
|
items.push(
|
||||||
MenuItemFields::new("Billing and usage")
|
MenuItemFields::new("Billing and usage")
|
||||||
.with_on_select_action(WorkspaceAction::ShowSettingsPage(
|
.with_on_select_action(WorkspaceAction::ShowSettingsPage(
|
||||||
SettingsSection::BillingAndUsage,
|
SettingsSection::About,
|
||||||
))
|
))
|
||||||
.into_item(),
|
.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
|
/// Opens the workspace-level blocking modal for creating a new managed
|
||||||
/// auth secret. Persists the new secret on success and dismisses the
|
/// auth secret. Persists the new secret on success and dismisses the
|
||||||
/// modal; cards adopt it via `HarnessAvailabilityEvent::AuthSecretCreated`.
|
/// 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")))]
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||||
fn restore_source_handoff_draft(
|
fn restore_source_handoff_draft(
|
||||||
source_view: &ViewHandle<TerminalView>,
|
source_view: &ViewHandle<TerminalView>,
|
||||||
@@ -16895,13 +16716,7 @@ impl Workspace {
|
|||||||
pane_group::Event::OpenAgentProfileEditor { profile_id } => {
|
pane_group::Event::OpenAgentProfileEditor { profile_id } => {
|
||||||
self.open_execution_profile_editor_pane(None, *profile_id, ctx);
|
self.open_execution_profile_editor_pane(None, *profile_id, ctx);
|
||||||
}
|
}
|
||||||
pane_group::Event::OpenEnvironmentManagementPane => {
|
pane_group::Event::OpenEnvironmentManagementPane => {}
|
||||||
self.open_environment_management_pane(
|
|
||||||
None,
|
|
||||||
crate::settings_view::environments_page::EnvironmentsPage::Create,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pane_group::Event::OpenLspLogs { log_path } => {
|
pane_group::Event::OpenLspLogs { log_path } => {
|
||||||
self.open_lsp_logs(log_path, ctx);
|
self.open_lsp_logs(log_path, ctx);
|
||||||
}
|
}
|
||||||
@@ -23872,12 +23687,8 @@ impl TypedActionView for Workspace {
|
|||||||
let _ = (terminal_view_id, conversation_id, trigger);
|
let _ = (terminal_view_id, conversation_id, trigger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ShowHandoffEnvironmentCreationModal => {
|
ShowHandoffEnvironmentCreationModal => {}
|
||||||
self.show_handoff_environment_creation_modal(ctx);
|
ShowCloudModeV2EnvironmentCreationModal => {}
|
||||||
}
|
|
||||||
ShowCloudModeV2EnvironmentCreationModal => {
|
|
||||||
self.show_cloud_mode_v2_environment_creation_modal(ctx);
|
|
||||||
}
|
|
||||||
OpenCreateAuthSecretModal { harness } => {
|
OpenCreateAuthSecretModal { harness } => {
|
||||||
self.show_create_auth_secret_modal(*harness, ctx);
|
self.show_create_auth_secret_modal(*harness, ctx);
|
||||||
}
|
}
|
||||||
@@ -23994,7 +23805,7 @@ impl TypedActionView for Workspace {
|
|||||||
ctx.open_url(&upgrade_url);
|
ctx.open_url(&upgrade_url);
|
||||||
}
|
}
|
||||||
ShowReferralSettingsPage => {
|
ShowReferralSettingsPage => {
|
||||||
self.show_settings_with_section(Some(SettingsSection::Referrals), ctx);
|
self.show_settings_with_section(Some(SettingsSection::About), ctx);
|
||||||
}
|
}
|
||||||
JoinSlack => self.join_slack(ctx),
|
JoinSlack => self.join_slack(ctx),
|
||||||
ViewUserDocs => self.view_user_docs(ctx),
|
ViewUserDocs => self.view_user_docs(ctx),
|
||||||
@@ -24946,9 +24757,7 @@ impl TypedActionView for Workspace {
|
|||||||
ctx
|
ctx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
OpenEnvironmentManagementPane => {
|
OpenEnvironmentManagementPane => {}
|
||||||
self.open_environment_management_pane(None, EnvironmentsPage::Create, ctx);
|
|
||||||
}
|
|
||||||
ToggleAIDocumentPane {
|
ToggleAIDocumentPane {
|
||||||
document_id,
|
document_id,
|
||||||
document_version,
|
document_version,
|
||||||
@@ -26986,10 +26795,6 @@ impl View for Workspace {
|
|||||||
stack.add_child(ChildView::new(lightbox_view).finish());
|
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 {
|
if let Some(create_auth_secret_modal) = &self.create_auth_secret_modal {
|
||||||
stack.add_child(ChildView::new(create_auth_secret_modal).finish());
|
stack.add_child(ChildView::new(create_auth_secret_modal).finish());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ impl SseClient for reqwest::Client {
|
|||||||
return Err(SseTransportError::UnexpectedContentType(None));
|
return Err(SseTransportError::UnexpectedContentType(None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
|
let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed();
|
||||||
Ok(event_stream)
|
Ok(event_stream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.92.0"
|
channel = "1.94.1"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
Reference in New Issue
Block a user