first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+335 -116
View File
@@ -1,45 +1,95 @@
#![allow(dead_code, unused_imports, unused_variables)]
use super::{
platform::{
CreateApiKeyModal, CreateApiKeyModalEvent, CreateApiKeyModalViewState, ExpireApiKeyButton,
ExpireApiKeyButtonEvent,
},
settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
CONTENT_FONT_SIZE, SUBHEADER_FONT_SIZE,
},
SettingsSection,
};
use crate::auth::AuthStateProvider;
use crate::server::{ids::ApiKeyUid, server_api::auth::AuthClient};
use crate::util::truncation::truncate_from_end;
use crate::{
appearance::Appearance,
modal::{Modal, ModalEvent, ModalViewState},
ui_components::icons::Icon,
util::time_format::format_approx_duration_from_now_utc,
};
use chrono::{DateTime, Utc};
use galaxy_core::features::FeatureFlag;
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Element, Empty,
Expanded, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisSize, MouseStateHandle,
Padding, ParentElement, Shrinkable, Text,
},
fonts::{Properties, Weight},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use galaxy_core::features::FeatureFlag;
use galaxy_graphql::object_permissions::OwnerType;
use galaxy_graphql::queries::api_keys::ApiKeyProperties as GqlApiKeyProperties;
use galaxyui::elements::{
resizable_state_handle, Align, Border, ChildView, ConstrainedBox, Container,
CrossAxisAlignment, DragBarSide, Element, Empty, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisSize, MouseStateHandle, Padding, ParentElement, Resizable,
ResizableStateHandle, Shrinkable, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use super::platform::{
CreateApiKeyModal, CreateApiKeyModalEvent, CreateApiKeyModalViewState, ExpireApiKeyButton,
ExpireApiKeyButtonEvent,
};
use super::settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget,
CONTENT_FONT_SIZE, SUBHEADER_FONT_SIZE,
};
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::modal::{Modal, ModalEvent, ModalViewState};
use crate::search_bar::SearchBar;
use crate::server::ids::ApiKeyUid;
use crate::ui_components::icons::Icon;
use crate::util::time_format::format_approx_duration_from_now_utc;
const MODAL_WIDTH: f32 = 460.;
const MODAL_HEIGHT: f32 = 320.;
const API_KEY_DOCS_URL: &str = "https://docs.warp.dev/reference/cli/api-keys";
const API_KEY_NAME_COLUMN_DEFAULT_WIDTH: f32 = 220.;
const API_KEY_NAME_COLUMN_MIN_WIDTH: f32 = 120.;
const API_KEY_KEY_COLUMN_WIDTH: f32 = 120.;
const API_KEY_METADATA_COLUMN_MIN_WIDTH: f32 = 80.;
const API_KEY_ACTION_COLUMN_MIN_WIDTH: f32 = 48.;
const API_KEY_TABLE_MIN_NON_RESIZABLE_COLUMNS_WIDTH: f32 = API_KEY_KEY_COLUMN_WIDTH
+ (API_KEY_METADATA_COLUMN_MIN_WIDTH * 3.)
+ API_KEY_ACTION_COLUMN_MIN_WIDTH;
const API_KEY_TABLE_MIN_SCOPE_COLUMN_WIDTH: f32 = API_KEY_METADATA_COLUMN_MIN_WIDTH;
const API_KEY_TABLE_LAYOUT_SAFETY_PADDING: f32 = 16.;
const SETTINGS_SIDEBAR_WIDTH_DEFAULT: f32 = 200.;
const SETTINGS_SIDEBAR_WIDTH_WITH_FOOTER: f32 = 248.;
const SETTINGS_SECTION_BORDER_WIDTH: f32 = 1.;
const SETTINGS_PAGE_HORIZONTAL_PADDING: f32 = 56.;
const SETTINGS_PAGE_MAX_CONTENT_WIDTH: f32 = 800.;
const API_KEY_SEARCH_BAR_MAX_WIDTH: f32 = 640.;
fn settings_sidebar_width_for_platform_page() -> f32 {
if FeatureFlag::SettingsFile.is_enabled() {
SETTINGS_SIDEBAR_WIDTH_WITH_FOOTER
} else {
SETTINGS_SIDEBAR_WIDTH_DEFAULT
}
}
fn api_key_table_width_chrome() -> f32 {
settings_sidebar_width_for_platform_page()
+ SETTINGS_SECTION_BORDER_WIDTH
+ SETTINGS_PAGE_HORIZONTAL_PADDING
+ API_KEY_TABLE_LAYOUT_SAFETY_PADDING
}
fn api_key_table_min_non_resizable_columns_width(show_scope_column: bool) -> f32 {
if show_scope_column {
API_KEY_TABLE_MIN_NON_RESIZABLE_COLUMNS_WIDTH + API_KEY_TABLE_MIN_SCOPE_COLUMN_WIDTH
} else {
API_KEY_TABLE_MIN_NON_RESIZABLE_COLUMNS_WIDTH
}
}
fn compute_api_key_name_column_max_width(
window_width: f32,
min_width: f32,
min_non_resizable_columns_width: f32,
table_width_chrome: f32,
) -> f32 {
let available_table_width =
(window_width - table_width_chrome).clamp(0., SETTINGS_PAGE_MAX_CONTENT_WIDTH);
(available_table_width - min_non_resizable_columns_width).max(min_width)
}
#[derive(Clone, Copy)]
pub enum PlatformPageViewEvent {
@@ -57,6 +107,10 @@ pub struct PlatformPageView {
page: PageType<Self>,
create_api_key_modal_state: CreateApiKeyModalViewState,
api_keys: Vec<APIKeyProperties>,
api_key_search_query: String,
api_key_search_editor: ViewHandle<EditorView>,
api_key_search_bar: ViewHandle<SearchBar>,
api_key_table_column_widths: ApiKeyTableColumnWidths,
expire_buttons: HashMap<ApiKeyUid, ViewHandle<ExpireApiKeyButton>>,
is_loading: bool,
documentation_link_highlight: HighlightedHyperlink,
@@ -71,10 +125,11 @@ impl PlatformPageView {
}
// Build and send the GraphQL query
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.list_api_keys().await },
async move { auth_client.list_api_keys().await },
|me, res, ctx| {
me.is_loading = false;
match res {
@@ -82,26 +137,9 @@ impl PlatformPageView {
me.api_keys = keys
.into_iter()
.map(|gql_key| {
// Ensure the per-key expire button exists
let uid = gql_key.uid.into_inner();
me.ensure_expire_button_for_key(ctx, uid.clone());
let scope = match gql_key.owner_type {
galaxy_graphql::object_permissions::OwnerType::User => {
ApiKeyScope::Personal
}
galaxy_graphql::object_permissions::OwnerType::Team => {
ApiKeyScope::Team
}
};
APIKeyProperties::new(
uid,
gql_key.name,
gql_key.key_suffix,
scope,
gql_key.created_at.utc(),
gql_key.last_used_at.map(|t| t.utc()),
gql_key.expires_at.map(|t| t.utc()),
)
let ui_key = APIKeyProperties::from(&gql_key);
me.ensure_expire_button_for_key(ctx, ui_key.uid.clone());
ui_key
})
.collect();
ctx.notify();
@@ -120,13 +158,33 @@ impl PlatformPageView {
);
}
pub fn new(ctx: &mut ViewContext<PlatformPageView>) -> Self {
// Create the modal body
let api_key_search_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions {
font_size_override: Some(appearance.ui_font_size()),
font_family_override: Some(appearance.ui_font_family()),
..Default::default()
},
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Search API keys", ctx);
editor
});
ctx.subscribe_to_view(&api_key_search_editor, |me, _, event, ctx| {
me.handle_search_editor_event(event, ctx);
});
let api_key_search_bar =
ctx.add_typed_action_view(|_| SearchBar::new(api_key_search_editor.clone()));
let create_api_key_body = ctx.add_typed_action_view(CreateApiKeyModal::new);
ctx.subscribe_to_view(&create_api_key_body, |me, _, event, ctx| {
me.handle_create_api_key_modal_event(event, ctx);
});
// Create the modal wrapper
let create_api_key_modal_view = ctx.add_typed_action_view(|ctx| {
Modal::new(Some("New API key".to_string()), create_api_key_body, ctx)
.with_modal_style(UiComponentStyles {
@@ -167,6 +225,10 @@ impl PlatformPageView {
create_api_key_modal_view,
)),
api_keys: vec![],
api_key_search_query: String::new(),
api_key_search_editor,
api_key_search_bar,
api_key_table_column_widths: ApiKeyTableColumnWidths::default(),
expire_buttons: HashMap::new(),
is_loading: true,
documentation_link_highlight: HighlightedHyperlink::default(),
@@ -174,7 +236,6 @@ impl PlatformPageView {
}
fn show_create_api_key_modal(&mut self, ctx: &mut ViewContext<Self>) {
// Ensure header reads "New API key" when opening the form
self.create_api_key_modal_state
.set_title(Some("New API key".to_string()), ctx);
self.create_api_key_modal_state.open(ctx);
@@ -204,32 +265,14 @@ impl PlatformPageView {
self.hide_create_api_key_modal(ctx);
}
CreateApiKeyModalEvent::Created { api_key } => {
// Switch modal header off for success screen
self.create_api_key_modal_state
.set_title(Some("Save your key".to_string()), ctx);
// Append to list locally
// Ensure the per-key expire button exists
let uid = api_key.uid.clone().into_inner();
self.ensure_expire_button_for_key(ctx, uid.clone());
let scope = match api_key.owner_type {
galaxy_graphql::object_permissions::OwnerType::User => ApiKeyScope::Personal,
galaxy_graphql::object_permissions::OwnerType::Team => ApiKeyScope::Team,
};
let ui_key = APIKeyProperties::new(
uid,
api_key.name.clone(),
api_key.key_suffix.clone(),
scope,
api_key.created_at.utc(),
api_key.last_used_at.map(|t| t.utc()),
api_key.expires_at.map(|t| t.utc()),
);
let ui_key = APIKeyProperties::from(api_key);
self.ensure_expire_button_for_key(ctx, ui_key.uid.clone());
self.api_keys.push(ui_key);
ctx.notify();
}
CreateApiKeyModalEvent::Error { message } => {
// Show an error toast with the provided message
let window_id = ctx.window_id();
crate::ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::error(message.clone());
@@ -240,6 +283,23 @@ impl PlatformPageView {
}
}
fn handle_search_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Edited(_) => {
self.api_key_search_query = self.api_key_search_editor.as_ref(ctx).buffer_text(ctx);
ctx.notify();
}
EditorEvent::Escape => {
self.api_key_search_query.clear();
self.api_key_search_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
ctx.notify();
}
_ => {}
}
}
pub fn get_modal_content(&self) -> Option<Box<dyn Element>> {
if self.create_api_key_modal_state.is_open() {
Some(self.create_api_key_modal_state.render())
@@ -314,6 +374,7 @@ struct APIKeyProperties {
name: String,
key_suffix: String,
scope: ApiKeyScope,
agent_name: Option<String>,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
@@ -323,30 +384,79 @@ struct APIKeyProperties {
enum ApiKeyScope {
Personal,
Team,
/// Not yet constructed — the server doesn't distinguish agent-scoped keys
/// from team keys yet, but the create modal already supports the Agent
/// type and the render path needs this variant for display.
#[allow(dead_code)]
Agent,
}
impl APIKeyProperties {
fn new(
uid: ApiKeyUid,
name: impl Into<String>,
key_suffix: impl Into<String>,
scope: ApiKeyScope,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
) -> Self {
fn matches_search_query(&self, query: &str, include_agent_names: bool) -> bool {
let query = query.trim();
if query.is_empty() {
return true;
}
let needle = query.to_lowercase();
self.name.to_lowercase().contains(&needle)
|| (include_agent_names
&& self
.agent_name
.as_ref()
.is_some_and(|agent_name| agent_name.to_lowercase().contains(&needle)))
}
}
impl From<&GqlApiKeyProperties> for APIKeyProperties {
fn from(gql_key: &GqlApiKeyProperties) -> Self {
let agent_name = gql_key.agent_info.as_ref().map(|agent| agent.name.clone());
let scope = if agent_name.is_some() {
ApiKeyScope::Agent
} else {
match gql_key.owner_type {
OwnerType::User => ApiKeyScope::Personal,
OwnerType::Team => ApiKeyScope::Team,
}
};
Self {
uid,
name: name.into(),
key_suffix: key_suffix.into(),
uid: gql_key.uid.clone().into_inner(),
name: gql_key.name.clone(),
key_suffix: gql_key.key_suffix.clone(),
scope,
created_at,
last_used_at,
expires_at,
agent_name,
created_at: gql_key.created_at.utc(),
last_used_at: gql_key.last_used_at.map(|t| t.utc()),
expires_at: gql_key.expires_at.map(|t| t.utc()),
}
}
}
struct ApiKeyTableColumnWidths {
name: ResizableStateHandle,
}
impl Default for ApiKeyTableColumnWidths {
fn default() -> Self {
Self {
name: resizable_state_handle(API_KEY_NAME_COLUMN_DEFAULT_WIDTH),
}
}
}
impl ApiKeyTableColumnWidths {
fn width(state_handle: &ResizableStateHandle) -> f32 {
state_handle
.lock()
.expect("API key table column width handle should lock")
.size()
}
fn name_width(&self) -> f32 {
Self::width(&self.name)
}
}
#[derive(Default)]
struct PlatformPageWidget {
create_api_key_button_mouse_state: MouseStateHandle,
@@ -417,6 +527,7 @@ impl PlatformPageWidget {
Text::new_inline("Oz Cloud API Keys", appearance.ui_font_family(), 16.)
.with_style(Properties::default().weight(Weight::Bold))
.with_color(appearance.theme().active_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_child(Shrinkable::new(1.0, Empty::new().finish()).finish())
@@ -449,22 +560,61 @@ impl PlatformPageWidget {
col.add_child(self.render_zero_state(appearance));
}
} else {
col.add_child(self.render_api_keys_header(appearance));
col.add_child(self.render_api_keys_rows(appearance, view, api_keys));
col.add_child(
Container::new(
ConstrainedBox::new(ChildView::new(&view.api_key_search_bar).finish())
.with_max_width(API_KEY_SEARCH_BAR_MAX_WIDTH)
.finish(),
)
.with_margin_top(16.)
.finish(),
);
let include_agent_names = FeatureFlag::NamedAgents.is_enabled();
let filtered_api_keys: Vec<&APIKeyProperties> = api_keys
.iter()
.filter(|key| {
key.matches_search_query(&view.api_key_search_query, include_agent_names)
})
.collect();
if filtered_api_keys.is_empty() {
col.add_child(self.render_no_search_results(appearance));
} else {
col.add_child(self.render_api_keys_header(appearance, view));
col.add_child(self.render_api_keys_rows(appearance, view, &filtered_api_keys));
}
}
col.finish()
}
fn render_api_keys_header(&self, appearance: &Appearance) -> Box<dyn Element> {
fn render_api_keys_header(
&self,
appearance: &Appearance,
view: &PlatformPageView,
) -> Box<dyn Element> {
let table_width_chrome = api_key_table_width_chrome();
let show_scope_column =
FeatureFlag::TeamApiKeys.is_enabled() || FeatureFlag::NamedAgents.is_enabled();
let min_non_resizable_columns_width =
api_key_table_min_non_resizable_columns_width(show_scope_column);
let mut header_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
header_row
.add_child(Expanded::new(1., self.render_header_cell(appearance, "Name")).finish());
header_row
.add_child(Expanded::new(1., self.render_header_cell(appearance, "Key")).finish());
if FeatureFlag::TeamApiKeys.is_enabled() {
header_row.add_child(self.render_resizable_header_cell(
appearance,
"Name",
view.api_key_table_column_widths.name.clone(),
API_KEY_NAME_COLUMN_MIN_WIDTH,
min_non_resizable_columns_width,
table_width_chrome,
));
header_row.add_child(
ConstrainedBox::new(self.render_header_cell(appearance, "Key"))
.with_width(API_KEY_KEY_COLUMN_WIDTH)
.finish(),
);
if show_scope_column {
header_row.add_child(
Expanded::new(1., self.render_header_cell(appearance, "Scope")).finish(),
);
@@ -486,11 +636,59 @@ impl PlatformPageWidget {
.finish()
}
fn render_resizable_header_cell(
&self,
appearance: &Appearance,
label: &str,
width_handle: ResizableStateHandle,
min_width: f32,
min_non_resizable_columns_width: f32,
table_width_chrome: f32,
) -> Box<dyn Element> {
let width = width_handle
.lock()
.expect("API key header width handle should lock")
.size();
let header_cell = ConstrainedBox::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Expanded::new(1., self.render_header_cell(appearance, label)).finish())
.with_child(
Container::new(
Text::new_inline("⋮", appearance.ui_font_family(), CONTENT_FONT_SIZE)
.with_color(appearance.theme().nonactive_ui_detail().into())
.finish(),
)
.with_padding_right(3.)
.finish(),
)
.finish(),
)
.with_width(width)
.finish();
Resizable::new(width_handle, header_cell)
.with_dragbar_side(DragBarSide::Right)
.with_bounds_callback(Box::new(move |window_size| {
let max_width = compute_api_key_name_column_max_width(
window_size.x(),
min_width,
min_non_resizable_columns_width,
table_width_chrome,
);
(min_width, max_width)
}))
.on_resize(|ctx, _| {
ctx.notify();
})
.finish()
}
fn render_api_keys_rows(
&self,
appearance: &Appearance,
view: &PlatformPageView,
api_keys: &[APIKeyProperties],
api_keys: &[&APIKeyProperties],
) -> Box<dyn Element> {
let mut col = Flex::column();
for key in api_keys.iter() {
@@ -508,6 +706,7 @@ impl PlatformPageWidget {
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(appearance.theme().nonactive_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_padding(Padding::uniform(8.))
@@ -528,29 +727,28 @@ impl PlatformPageWidget {
.expires_at
.map(|dt| format!("{}", dt.format("%b %-d, %Y")))
.unwrap_or_else(|| "Never".to_owned());
// Truncate long names to keep columns aligned
let name_display = truncate_from_end(&key.name, 21);
let name_column_width = view.api_key_table_column_widths.name_width();
let key_column_width = API_KEY_KEY_COLUMN_WIDTH;
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
// TODO: use appearance.ui_font_size() instead of hardcoded 12
row.add_child(
Expanded::new(
1.,
ConstrainedBox::new(
Container::new(
Text::new_inline(name_display, appearance.ui_font_family(), 13.)
Text::new_inline(key.name.clone(), appearance.ui_font_family(), 13.)
.with_color(appearance.theme().active_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.with_width(name_column_width)
.finish(),
);
row.add_child(
Expanded::new(
1.,
ConstrainedBox::new(
Container::new(
Text::new_inline(
format!("wk-**{}", key.key_suffix),
@@ -558,17 +756,20 @@ impl PlatformPageWidget {
12.,
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_padding(Padding::uniform(8.))
.finish(),
)
.with_width(key_column_width)
.finish(),
);
if FeatureFlag::TeamApiKeys.is_enabled() {
if FeatureFlag::TeamApiKeys.is_enabled() || FeatureFlag::NamedAgents.is_enabled() {
let scope_display = match key.scope {
ApiKeyScope::Personal => "Personal",
ApiKeyScope::Team => "Team",
ApiKeyScope::Agent => "Agent",
};
row.add_child(
Expanded::new(
@@ -687,6 +888,20 @@ impl PlatformPageWidget {
.with_margin_top(80.)
.finish()
}
fn render_no_search_results(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new(
"No API keys match your search",
appearance.ui_font_family(),
CONTENT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(),
)
.with_margin_top(24.)
.finish()
}
}
impl SettingsPageMeta for PlatformPageView {
@@ -725,3 +940,7 @@ impl From<ViewHandle<PlatformPageView>> for SettingsPageViewHandle {
SettingsPageViewHandle::OzCloudAPIKeys(view_handle)
}
}
#[cfg(test)]
#[path = "platform_page_tests.rs"]
mod tests;