first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,21 +1,14 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use crate::editor::Event as EditorEvent;
|
||||
use crate::modal::{Modal, ModalViewState};
|
||||
use crate::server::server_api::auth::AuthClient;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
|
||||
view_components::{Dropdown as DropdownView, DropdownItem},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_server_client::auth::AgentIdentity;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildView, ConstrainedBox, Container, CornerRadius, Empty, Fill, Flex,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Empty, Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, ParentElement,
|
||||
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Stack, Text,
|
||||
};
|
||||
use galaxyui::elements::{CrossAxisAlignment, Expanded, MainAxisAlignment, MainAxisSize, Padding};
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::segmented_control::{
|
||||
@@ -24,15 +17,31 @@ use galaxyui::ui_components::segmented_control::{
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
};
|
||||
use crate::modal::{Modal, ModalViewState};
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use crate::view_components::dropdown::{DROPDOWN_PADDING, TOP_MENU_BAR_HEIGHT};
|
||||
use crate::view_components::{Dropdown as DropdownView, DropdownItem, FilterableDropdown};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
const OZ_AGENTS_URL: &str = "https://oz.warp.dev/agents?new=true";
|
||||
const API_KEY_DOCS_URL: &str =
|
||||
"https://docs.warp.dev/reference/cli/api-keys/#personal-vs-agent-keys";
|
||||
|
||||
const LABEL_FONT_SIZE: f32 = 14.;
|
||||
const INPUT_WIDTH: f32 = 428.; // 460px - (2 * 16px) padding
|
||||
const AGENT_DROPDOWN_POSITION_ID: &str = "create_api_key_modal_agent_dropdown";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ApiKeyType {
|
||||
Personal,
|
||||
Team,
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl ApiKeyType {
|
||||
@@ -44,6 +53,9 @@ impl ApiKeyType {
|
||||
ApiKeyType::Team => {
|
||||
"This API key is tied to your team and can make requests on behalf of your team."
|
||||
}
|
||||
ApiKeyType::Agent => {
|
||||
"This API key is tied to an agent and can make requests on behalf of the agent."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,14 +63,20 @@ impl ApiKeyType {
|
||||
pub struct CreateApiKeyModal {
|
||||
name_editor: ViewHandle<EditorView>,
|
||||
expiration_dropdown: ViewHandle<DropdownView<CreateApiKeyModalAction>>,
|
||||
agent_dropdown: ViewHandle<FilterableDropdown<CreateApiKeyModalAction>>,
|
||||
api_key_type_control: ViewHandle<SegmentedControl<ApiKeyType>>,
|
||||
expiration: ExpirationOption,
|
||||
cancel_button_mouse_state: MouseStateHandle,
|
||||
create_button_mouse_state: MouseStateHandle,
|
||||
create_agent_button_mouse_state: MouseStateHandle,
|
||||
request_state: RequestState,
|
||||
raw_key_copied: bool,
|
||||
raw_key: Option<String>,
|
||||
has_team: bool,
|
||||
has_named_agents: bool,
|
||||
agents: Vec<AgentIdentity>,
|
||||
selected_agent_uid: Option<String>,
|
||||
is_loading_agents: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -104,6 +122,8 @@ pub enum CreateApiKeyModalAction {
|
||||
Create,
|
||||
CopyRawKey,
|
||||
SetExpiration(ExpirationOption),
|
||||
SelectAgent(String),
|
||||
CreateNewAgent,
|
||||
}
|
||||
|
||||
pub enum CreateApiKeyModalEvent {
|
||||
@@ -129,6 +149,7 @@ impl CreateApiKeyModal {
|
||||
|
||||
let has_team = FeatureFlag::TeamApiKeys.is_enabled()
|
||||
&& UserWorkspaces::as_ref(ctx).current_team_uid().is_some();
|
||||
let has_named_agents = FeatureFlag::NamedAgents.is_enabled();
|
||||
|
||||
let name_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let options = SingleLineEditorOptions {
|
||||
@@ -145,13 +166,22 @@ impl CreateApiKeyModal {
|
||||
editor
|
||||
});
|
||||
|
||||
// Expiration dropdown
|
||||
let expiration_dropdown =
|
||||
ctx.add_typed_action_view(DropdownView::<CreateApiKeyModalAction>::new);
|
||||
|
||||
// API key type segmented control
|
||||
let agent_dropdown =
|
||||
ctx.add_typed_action_view(FilterableDropdown::<CreateApiKeyModalAction>::new);
|
||||
agent_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_top_bar_max_width(INPUT_WIDTH);
|
||||
// Match the open menu width to the rendered top-bar (input) width so
|
||||
// the dropdown doesn't overhang the search field.
|
||||
dropdown.set_match_menu_width_to_top_bar(true, ctx);
|
||||
});
|
||||
|
||||
let api_key_type_control = ctx.add_typed_action_view(move |ctx| {
|
||||
let options = if has_team {
|
||||
let options = if has_named_agents {
|
||||
vec![ApiKeyType::Personal, ApiKeyType::Agent]
|
||||
} else if has_team {
|
||||
vec![ApiKeyType::Personal, ApiKeyType::Team]
|
||||
} else {
|
||||
vec![ApiKeyType::Personal]
|
||||
@@ -169,6 +199,7 @@ impl CreateApiKeyModal {
|
||||
label: match key_type {
|
||||
ApiKeyType::Personal => "Personal".into(),
|
||||
ApiKeyType::Team => "Team".into(),
|
||||
ApiKeyType::Agent => "Agent".into(),
|
||||
},
|
||||
width_override: Some(55.0),
|
||||
color: if is_selected {
|
||||
@@ -191,21 +222,22 @@ impl CreateApiKeyModal {
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&api_key_type_control, |me, _, _, ctx| {
|
||||
let selected = me.api_key_type_control.as_ref(ctx).selected_option();
|
||||
if selected == ApiKeyType::Agent && me.agents.is_empty() && !me.is_loading_agents {
|
||||
me.fetch_agents(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
me.name_editor.update(ctx, |_, ctx| ctx.notify());
|
||||
});
|
||||
|
||||
// Subscribe to UserWorkspaces to update has_team when team membership changes
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, _, ctx| {
|
||||
me.update_has_team(ctx);
|
||||
});
|
||||
|
||||
// Subscribe to editor events for navigation and validation
|
||||
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| {
|
||||
me.handle_name_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
// Populate expiration dropdown items and default selection (90 days)
|
||||
let default_expiration = ExpirationOption::NinetyDays;
|
||||
let items: Vec<DropdownItem<CreateApiKeyModalAction>> = ExpirationOption::all()
|
||||
.into_iter()
|
||||
@@ -218,7 +250,6 @@ impl CreateApiKeyModal {
|
||||
.collect();
|
||||
expiration_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
// Match the input width (460 - 2*16 padding = 428)
|
||||
dropdown.set_top_bar_max_width(INPUT_WIDTH);
|
||||
dropdown.set_menu_width(INPUT_WIDTH, ctx);
|
||||
dropdown.set_selected_by_action(
|
||||
@@ -230,24 +261,84 @@ impl CreateApiKeyModal {
|
||||
Self {
|
||||
name_editor,
|
||||
expiration_dropdown,
|
||||
agent_dropdown,
|
||||
api_key_type_control,
|
||||
expiration: default_expiration,
|
||||
cancel_button_mouse_state: Default::default(),
|
||||
create_button_mouse_state: Default::default(),
|
||||
create_agent_button_mouse_state: Default::default(),
|
||||
request_state: RequestState::Idle,
|
||||
raw_key_copied: false,
|
||||
raw_key: None,
|
||||
has_team,
|
||||
has_named_agents,
|
||||
agents: Vec::new(),
|
||||
selected_agent_uid: None,
|
||||
is_loading_agents: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_agents(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_loading_agents = true;
|
||||
ctx.notify();
|
||||
|
||||
let auth_client =
|
||||
crate::server::server_api::ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
ctx.spawn(
|
||||
async move { auth_client.list_agent_identities().await },
|
||||
|me, res, ctx| {
|
||||
me.is_loading_agents = false;
|
||||
match res {
|
||||
Ok(agents) => {
|
||||
me.agents = agents;
|
||||
me.populate_agent_dropdown(ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to load agent identities: {err}");
|
||||
ctx.emit(CreateApiKeyModalEvent::Error {
|
||||
message: "Failed to load agents. Please close and try again."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn populate_agent_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let items: Vec<DropdownItem<CreateApiKeyModalAction>> = self
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|a| a.available)
|
||||
.map(|agent| {
|
||||
DropdownItem::new(
|
||||
&agent.name,
|
||||
CreateApiKeyModalAction::SelectAgent(agent.uid.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.agent_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_agents_for_test(
|
||||
&mut self,
|
||||
agents: Vec<AgentIdentity>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.agents = agents;
|
||||
self.populate_agent_dropdown(ctx);
|
||||
}
|
||||
|
||||
fn create(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.request_state == RequestState::Pending {
|
||||
return;
|
||||
}
|
||||
let name = self.name_editor.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
// Always allow creation, even with empty name (we'll use a default)
|
||||
let final_name = if name.trim().is_empty() {
|
||||
"Warp API Key".to_string()
|
||||
} else {
|
||||
@@ -257,7 +348,6 @@ impl CreateApiKeyModal {
|
||||
self.request_state = RequestState::Pending;
|
||||
ctx.notify();
|
||||
|
||||
// Compute expiration timestamp based on selected option
|
||||
let expires_at = match self.expiration.days() {
|
||||
Some(days) => {
|
||||
let t = Utc::now() + chrono::Duration::days(days);
|
||||
@@ -266,15 +356,29 @@ impl CreateApiKeyModal {
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Get team_id if creating for team
|
||||
let for_team = self.api_key_type_control.as_ref(ctx).selected_option() == ApiKeyType::Team;
|
||||
let team_id = if for_team {
|
||||
let selected_type = self.api_key_type_control.as_ref(ctx).selected_option();
|
||||
|
||||
let agent_uid = if selected_type == ApiKeyType::Agent {
|
||||
match &self.selected_agent_uid {
|
||||
Some(uid) => Some(cynic::Id::new(uid.clone())),
|
||||
None => {
|
||||
self.request_state = RequestState::Idle;
|
||||
ctx.emit(CreateApiKeyModalEvent::Error {
|
||||
message: "Please select an agent.".to_string(),
|
||||
});
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let team_id = if selected_type == ApiKeyType::Team {
|
||||
let workspaces = UserWorkspaces::as_ref(ctx);
|
||||
match workspaces.current_team_uid() {
|
||||
Some(uid) => Some(cynic::Id::new(uid.uid())),
|
||||
None => {
|
||||
// Fail fast if the user requested a team key but there is no current team.
|
||||
// This can happen if the team state changed between render and click.
|
||||
self.request_state = RequestState::Idle;
|
||||
ctx.emit(CreateApiKeyModalEvent::Error {
|
||||
message:
|
||||
@@ -289,16 +393,14 @@ impl CreateApiKeyModal {
|
||||
None
|
||||
};
|
||||
|
||||
// Fire mutation via ServerApi AuthClient
|
||||
let server_api = crate::server::server_api::ServerApiProvider::as_ref(ctx).get();
|
||||
let auth_client =
|
||||
crate::server::server_api::ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
ctx.spawn(
|
||||
async move { server_api.create_api_key(final_name, team_id, expires_at).await },
|
||||
async move { auth_client.create_api_key(final_name, team_id, agent_uid, expires_at).await },
|
||||
|me, res, ctx| {
|
||||
match res {
|
||||
Ok(galaxy_graphql::mutations::generate_api_key::GenerateApiKeyResult::GenerateApiKeyOutput(output)) => {
|
||||
// Notify parent to append
|
||||
ctx.emit(CreateApiKeyModalEvent::Created { api_key: output.api_key });
|
||||
// Switch to success view and show raw key
|
||||
me.request_state = RequestState::Succeeded;
|
||||
me.raw_key_copied = false;
|
||||
me.raw_key = Some(output.raw_api_key);
|
||||
@@ -328,6 +430,7 @@ impl CreateApiKeyModal {
|
||||
self.request_state = RequestState::Idle;
|
||||
self.raw_key_copied = false;
|
||||
self.raw_key = None;
|
||||
self.selected_agent_uid = None;
|
||||
self.name_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
@@ -335,16 +438,22 @@ impl CreateApiKeyModal {
|
||||
|
||||
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus(&self.name_editor);
|
||||
if self.has_named_agents {
|
||||
self.fetch_agents(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_has_team(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let new_has_team = FeatureFlag::TeamApiKeys.is_enabled()
|
||||
&& UserWorkspaces::as_ref(ctx).current_team_uid().is_some();
|
||||
let new_has_named_agents = FeatureFlag::NamedAgents.is_enabled();
|
||||
|
||||
if new_has_team != self.has_team {
|
||||
if new_has_team != self.has_team || new_has_named_agents != self.has_named_agents {
|
||||
self.has_team = new_has_team;
|
||||
// Update the segmented control options
|
||||
let options = if new_has_team {
|
||||
self.has_named_agents = new_has_named_agents;
|
||||
let options = if new_has_named_agents {
|
||||
vec![ApiKeyType::Personal, ApiKeyType::Agent]
|
||||
} else if new_has_team {
|
||||
vec![ApiKeyType::Personal, ApiKeyType::Team]
|
||||
} else {
|
||||
vec![ApiKeyType::Personal]
|
||||
@@ -508,16 +617,34 @@ impl View for CreateApiKeyModal {
|
||||
match self.request_state {
|
||||
RequestState::Succeeded => self.render_success_content(app),
|
||||
_ => {
|
||||
// Entry form (Idle, Pending, Failed)
|
||||
let selected_key_type = self.api_key_type_control.as_ref(app).selected_option();
|
||||
|
||||
let description_text = Text::new(
|
||||
selected_key_type.description(),
|
||||
appearance.ui_font_family(),
|
||||
LABEL_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
let description_text = if selected_key_type == ApiKeyType::Agent {
|
||||
FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(selected_key_type.description()),
|
||||
FormattedTextFragment::plain_text(" "),
|
||||
FormattedTextFragment::hyperlink("Learn more", API_KEY_DOCS_URL),
|
||||
])]),
|
||||
LABEL_FONT_SIZE,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
theme.nonactive_ui_text_color().into(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers(|url, _, ctx| {
|
||||
ctx.open_url(&url.url);
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
Text::new(
|
||||
selected_key_type.description(),
|
||||
appearance.ui_font_family(),
|
||||
LABEL_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let name_label = Text::new("Name", appearance.ui_font_family(), LABEL_FONT_SIZE)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
@@ -525,6 +652,10 @@ impl View for CreateApiKeyModal {
|
||||
|
||||
let is_pending = self.request_state == RequestState::Pending;
|
||||
|
||||
let is_create_disabled = is_pending
|
||||
|| (selected_key_type == ApiKeyType::Agent
|
||||
&& (self.selected_agent_uid.is_none() || self.is_loading_agents));
|
||||
|
||||
let mut cancel_button_hover = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
@@ -558,7 +689,7 @@ impl View for CreateApiKeyModal {
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CreateApiKeyModalAction::Create);
|
||||
});
|
||||
if is_pending {
|
||||
if is_create_disabled {
|
||||
create_button_hover = create_button_hover.disable();
|
||||
}
|
||||
let create_button = create_button_hover.finish();
|
||||
@@ -576,9 +707,9 @@ impl View for CreateApiKeyModal {
|
||||
.finish();
|
||||
|
||||
let mut col = Flex::column();
|
||||
let mut render_agent_dropdown = false;
|
||||
|
||||
// Show segmented control only if user has a team
|
||||
if self.has_team {
|
||||
if self.has_team || self.has_named_agents {
|
||||
let type_label =
|
||||
Text::new("Type", appearance.ui_font_family(), LABEL_FONT_SIZE)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
@@ -596,6 +727,83 @@ impl View for CreateApiKeyModal {
|
||||
.with_margin_bottom(24.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if selected_key_type == ApiKeyType::Agent {
|
||||
let agent_label =
|
||||
Text::new("Agent", appearance.ui_font_family(), LABEL_FONT_SIZE)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish();
|
||||
col.add_child(Container::new(agent_label).with_margin_bottom(4.).finish());
|
||||
|
||||
let available_agents: Vec<&AgentIdentity> =
|
||||
self.agents.iter().filter(|a| a.available).collect();
|
||||
|
||||
if !self.is_loading_agents && available_agents.is_empty() {
|
||||
let empty_text = Text::new(
|
||||
"No agents available. Create one first.",
|
||||
appearance.ui_font_family(),
|
||||
LABEL_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let create_agent_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.create_agent_button_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Create agent".to_string())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CreateApiKeyModalAction::CreateNewAgent);
|
||||
})
|
||||
.finish();
|
||||
|
||||
col.add_child(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(empty_text).with_margin_bottom(8.).finish(),
|
||||
)
|
||||
.with_child(create_agent_button)
|
||||
.finish(),
|
||||
)
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_padding(Padding::uniform(16.))
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
);
|
||||
} else {
|
||||
// The agent list can grow long, so use a FilterableDropdown
|
||||
// (search input + substring filtering). Its open menu must
|
||||
// paint above the fields below it (Name/Expiration), so the
|
||||
// dropdown is hoisted into the modal's outermost Stack as a
|
||||
// positioned overlay child anchored to this placeholder,
|
||||
// rather than rendered inline in the column (which would let
|
||||
// later siblings paint over the open menu).
|
||||
render_agent_dropdown = true;
|
||||
col.add_child(
|
||||
Container::new(
|
||||
SavePosition::new(
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_width(INPUT_WIDTH)
|
||||
.with_height(TOP_MENU_BAR_HEIGHT + (2. * DROPDOWN_PADDING))
|
||||
.finish(),
|
||||
AGENT_DROPDOWN_POSITION_ID,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
col.add_child(Container::new(name_label).with_margin_bottom(4.).finish());
|
||||
col.add_child(
|
||||
ConstrainedBox::new(
|
||||
@@ -632,7 +840,24 @@ impl View for CreateApiKeyModal {
|
||||
);
|
||||
|
||||
col.add_child(buttons_row);
|
||||
col.finish()
|
||||
let mut stack = Stack::new()
|
||||
.with_constrain_absolute_children()
|
||||
.with_child(col.finish());
|
||||
if render_agent_dropdown {
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(ChildView::new(&self.agent_dropdown).finish())
|
||||
.with_width(INPUT_WIDTH)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
AGENT_DROPDOWN_POSITION_ID,
|
||||
vec2f(0., 0.),
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
PositionedElementAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -666,6 +891,13 @@ impl TypedActionView for CreateApiKeyModal {
|
||||
self.expiration = *exp;
|
||||
ctx.notify();
|
||||
}
|
||||
CreateApiKeyModalAction::SelectAgent(uid) => {
|
||||
self.selected_agent_uid = Some(uid.clone());
|
||||
ctx.notify();
|
||||
}
|
||||
CreateApiKeyModalAction::CreateNewAgent => {
|
||||
ctx.open_url(OZ_AGENTS_URL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -730,3 +962,7 @@ fn api_key_type_control_styles(app: &AppContext) -> UiComponentStyles {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "create_api_key_modal_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use warp_server_client::auth::AgentIdentity;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::App;
|
||||
|
||||
use super::CreateApiKeyModal;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
fn agent(uid: &str, name: &str, available: bool) -> AgentIdentity {
|
||||
AgentIdentity {
|
||||
uid: uid.to_string(),
|
||||
name: name.to_string(),
|
||||
available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for the searchable Agent picker in the New API key modal:
|
||||
/// the agent dropdown is a `FilterableDropdown`, lists only available agents,
|
||||
/// and filtering by a query narrows the visible list case-insensitively.
|
||||
#[test]
|
||||
fn test_agent_dropdown_is_searchable() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| SyncedInputState::mock());
|
||||
app.add_singleton_model(|_| VimRegisters::new());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
|
||||
let (_, view) = app.add_window(WindowStyle::NotStealFocus, CreateApiKeyModal::new);
|
||||
|
||||
// Populate the picker with several agents; the unavailable one should be
|
||||
// excluded from the list entirely.
|
||||
view.update(&mut app, |modal, ctx| {
|
||||
modal.set_agents_for_test(
|
||||
vec![
|
||||
agent("1", "Default Service Account", true),
|
||||
agent("2", "Ben's Agent", true),
|
||||
agent("3", "Server Migration Agent", true),
|
||||
agent("4", "Unavailable Agent", false),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Only the 3 available agents are listed, and all are visible with no filter.
|
||||
let total = view.read(&app, |modal, ctx| modal.agent_dropdown.as_ref(ctx).len());
|
||||
assert_eq!(total, 3, "only available agents should be listed");
|
||||
let all_visible = view.read(&app, |modal, ctx| {
|
||||
modal
|
||||
.agent_dropdown
|
||||
.as_ref(ctx)
|
||||
.visible_items_len_for_test(ctx)
|
||||
});
|
||||
assert_eq!(all_visible, 3);
|
||||
|
||||
// Typing a query filters the list case-insensitively.
|
||||
view.update(&mut app, |modal, ctx| {
|
||||
modal.agent_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_filter_query_for_test("BEN", ctx)
|
||||
});
|
||||
});
|
||||
let filtered = view.read(&app, |modal, ctx| {
|
||||
modal
|
||||
.agent_dropdown
|
||||
.as_ref(ctx)
|
||||
.visible_items_len_for_test(ctx)
|
||||
});
|
||||
assert_eq!(filtered, 1, "query should match only \"Ben's Agent\"");
|
||||
|
||||
// A non-matching query yields no matches.
|
||||
view.update(&mut app, |modal, ctx| {
|
||||
modal.agent_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_filter_query_for_test("zzz", ctx)
|
||||
});
|
||||
});
|
||||
let none = view.read(&app, |modal, ctx| {
|
||||
modal
|
||||
.agent_dropdown
|
||||
.as_ref(ctx)
|
||||
.visible_items_len_for_test(ctx)
|
||||
});
|
||||
assert_eq!(none, 0);
|
||||
|
||||
// Clearing the query restores the full list.
|
||||
view.update(&mut app, |modal, ctx| {
|
||||
modal.agent_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_filter_query_for_test("", ctx)
|
||||
});
|
||||
});
|
||||
let restored = view.read(&app, |modal, ctx| {
|
||||
modal
|
||||
.agent_dropdown
|
||||
.as_ref(ctx)
|
||||
.visible_items_len_for_test(ctx)
|
||||
});
|
||||
assert_eq!(restored, 3);
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::server::{ids::ApiKeyUid, server_api::auth::AuthClient};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{
|
||||
elements::MouseStateHandle, ui_components::components::UiComponent, AppContext, Element,
|
||||
Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::ui_components::{buttons::icon_button, icons::Icon};
|
||||
use crate::server::ids::ApiKeyUid;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum RequestState {
|
||||
@@ -45,10 +45,11 @@ impl ExpireApiKeyButton {
|
||||
self.request_state = RequestState::Pending;
|
||||
ctx.notify();
|
||||
|
||||
let server_api = crate::server::server_api::ServerApiProvider::as_ref(ctx).get();
|
||||
let auth_client =
|
||||
crate::server::server_api::ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
let uid_for_req = self.key_uid.clone();
|
||||
ctx.spawn(
|
||||
async move { server_api.expire_api_key(&uid_for_req).await },
|
||||
async move { auth_client.expire_api_key(&uid_for_req).await },
|
||||
move |me, res, ctx| match res {
|
||||
Ok(
|
||||
galaxy_graphql::mutations::expire_api_key::ExpireApiKeyResult::ExpireApiKeyOutput(
|
||||
|
||||
Reference in New Issue
Block a user