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
+15 -19
View File
@@ -1,24 +1,20 @@
use super::{
settings_page::{
MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget,
},
SettingsSection,
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{
Align, CacheOption, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Image,
MainAxisAlignment, MouseStateHandle, ParentElement, Wrap,
};
use crate::{
appearance::Appearance, channel::ChannelState, settings::app_icon::AppIconSettings,
workspace::WorkspaceAction,
};
use galaxyui::SingletonEntity;
use galaxyui::{
assets::asset_cache::AssetSource,
elements::{
Align, CacheOption, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, Image,
MainAxisAlignment, MouseStateHandle, ParentElement, Wrap,
},
ui_components::components::UiComponent,
AppContext, Entity, View, ViewContext, ViewHandle,
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, Entity, View, ViewContext, ViewHandle};
use super::settings_page::{
MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget,
};
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::channel::ChannelState;
use crate::themes::theme::ColorScheme;
use crate::workspace::WorkspaceAction;
pub struct AboutPageView {
page: PageType<Self>,
+10 -12
View File
@@ -1,6 +1,8 @@
use crate::{channel::ChannelState, server::ids::ServerId};
use galaxyui::AppContext;
use crate::channel::ChannelState;
use crate::server::ids::ServerId;
/// Shared admin panel actions and utilities for settings views
pub struct AdminActions;
@@ -20,17 +22,13 @@ impl AdminActions {
pub fn contact_support(ctx: &mut AppContext) {
ctx.open_url("mailto:support@warp.dev");
}
/// Open the contact sales page
pub fn contact_sales(ctx: &mut AppContext) {
ctx.open_url("https://warp.dev/contact-sales");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_admin_panel_link_generation() {
let team_uid = ServerId::from(12345);
let expected_link = format!("{}/admin/{}", ChannelState::server_root_url(), team_uid);
let actual_link = AdminActions::admin_panel_link_for_team(team_uid);
assert_eq!(actual_link, expected_link);
}
}
#[path = "admin_actions_tests.rs"]
mod tests;
@@ -0,0 +1,9 @@
use super::*;
#[test]
fn test_admin_panel_link_generation() {
let team_uid = ServerId::from(12345);
let expected_link = format!("{}/admin/{}", ChannelState::server_root_url(), team_uid);
let actual_link = AdminActions::admin_panel_link_for_team(team_uid);
assert_eq!(actual_link, expected_link);
}
@@ -4,50 +4,43 @@ use std::{
time::Duration,
};
use galaxy_core::{
features::FeatureFlag, paths::home_relative_path, ui::theme::color::internal_colors,
};
use galaxyui::{
elements::{
Align, Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty, Expanded, Flex,
MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
},
fonts::{Properties, Weight},
platform::{file_picker::FilePickerError, FilePickerConfiguration},
r#async::{SpawnedFutureHandle, Timer},
ui_components::components::UiComponent,
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_color::ColorU;
use crate::{
appearance::Appearance,
themes::theme::Blend,
ui_components::{
buttons::icon_button,
dialog::{dialog_styles, Dialog},
icons::Icon,
},
view_components::{
action_button::{ActionButton, ButtonSize, PrimaryTheme, SecondaryTheme},
DismissibleToast,
},
workspace::ToastStack,
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use git2::Repository as GitRepository;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
#[cfg(all(
feature = "local_fs",
not(target_family = "wasm"),
not(any(test, feature = "integration_tests"))
))]
use ai::index::full_source_code_embedding::manager::CodebaseIndexManagerEvent;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use git2::Repository as GitRepository;
use pathfinder_color::ColorU;
use galaxy_core::features::FeatureFlag;
use galaxy_core::paths::home_relative_path;
use galaxy_core::ui::theme::color::internal_colors;
use warpui::elements::{
Align, Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty, Expanded, Flex,
MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::file_picker::FilePickerError;
use warpui::platform::FilePickerConfiguration;
use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::ui_components::components::UiComponent;
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::appearance::Appearance;
use crate::modal::MODAL_BACKDROP_OPACITY;
use crate::themes::theme::Blend;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ButtonSize, PrimaryTheme, SecondaryTheme,
};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
const DIALOG_WIDTH: f32 = 600.;
const AVAILABLE_LIST_MAX_HEIGHT: f32 = 260.;
@@ -149,8 +142,8 @@ impl AgentAssistedEnvironmentModal {
}
match event {
CodebaseIndexManagerEvent::SyncStateUpdated
| CodebaseIndexManagerEvent::NewIndexCreated
CodebaseIndexManagerEvent::SyncStateUpdated { .. }
| CodebaseIndexManagerEvent::NewIndexCreated { .. }
| CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { .. }
| CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => {
me.refresh_available_repos(ctx);
@@ -663,7 +656,7 @@ impl AgentAssistedEnvironmentModal {
.finish();
Container::new(Align::new(dialog).finish())
.with_background_color(ColorU::new(0, 0, 0, 179))
.with_background_color(ColorU::new(0, 0, 0, MODAL_BACKDROP_OPACITY))
.with_corner_radius(app.windows().window_corner_radius())
.finish()
}
@@ -1,14 +1,15 @@
use super::*;
use std::path::PathBuf;
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ToastStack;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{ChildView, Empty};
use galaxyui::platform::WindowStyle;
use galaxyui::{App, AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
use std::path::PathBuf;
use super::*;
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ToastStack;
fn init_modal_test_models(app: &mut App) {
initialize_settings_for_tests(app);
File diff suppressed because it is too large Load Diff
+374 -122
View File
@@ -1,39 +1,69 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use ::settings::{Setting, SettingSection, ToggleableSetting};
use enum_iterator::all;
use galaxy_core::ui::theme::color::internal_colors;
use warp_util::path::user_friendly_path;
use warpui::elements::{
Align, Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Element, Empty, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text, Wrap,
DEFAULT_UI_LINE_HEIGHT_RATIO,
};
use warpui::fonts::{FamilyId, FontInfo, Weight};
use warpui::keymap::{ContextPredicate, FixedBinding};
use warpui::platform::{Cursor, FilePickerConfiguration, GraphicsBackend, SystemTheme};
use warpui::rendering::ThinStrokes;
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::radio_buttons::{
RadioButtonItem, RadioButtonLayout, RadioButtonStateHandle,
};
use warpui::ui_components::slider::SliderStateHandle;
use warpui::ui_components::switch::SwitchStateHandle;
use warpui::units::IntoPixels;
use warpui::{
id, Action, AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateModel,
View, ViewContext, ViewHandle, WindowId,
};
use super::directory_color_add_picker::{DirectoryColorAddPicker, DirectoryColorAddPickerEvent};
use super::settings_page::{
AdditionalInfo, Category, LocalOnlyIconState, MatchData, PageType, SettingsWidget,
CONTENT_FONT_SIZE,
build_reset_button, render_body_item, render_body_item_label, render_dropdown_item,
AdditionalInfo, Category, LocalOnlyIconState, MatchData, PageType, SettingsPageEvent,
SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState, CONTENT_FONT_SIZE,
HEADER_PADDING,
};
use super::{flags, SettingsSection};
use super::{
settings_page::{
build_reset_button, render_body_item, render_body_item_label, render_dropdown_item,
SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle, ToggleState, HEADER_PADDING,
},
SettingsAction,
flags, SettingActionPairContexts, SettingActionPairDescriptions, SettingsAction,
SettingsSection, ToggleSettingActionPair,
};
use super::{SettingActionPairContexts, SettingActionPairDescriptions, ToggleSettingActionPair};
use crate::appearance::{Appearance, AppearanceEvent};
use crate::channel::{Channel, ChannelState};
use crate::context_chips::prompt::PromptEvent;
use crate::context_chips::renderer::ChipDragState;
use crate::context_chips::{
prompt::Prompt, renderer::Renderer as ContextChipRenderer, ChipAvailability,
};
use crate::context_chips::prompt::{Prompt, PromptEvent};
use crate::context_chips::renderer::{ChipDragState, Renderer as ContextChipRenderer};
use crate::context_chips::ChipAvailability;
use crate::editor::{
EditOrigin, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions,
EditOrigin, EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions,
TextOptions,
};
use crate::features::FeatureFlag;
use crate::gpu_state::{GPUState, GPUStateEvent};
use crate::prompt::editor_modal::OpenSource as PromptEditorOpenSource;
use crate::server::telemetry::InputUXChangeOrigin;
use crate::server::telemetry::{InputUXChangeOrigin, TelemetryEvent};
use crate::settings::app_icon::{AppIcon, AppIconSettings, ShowDockIconState};
use crate::settings::{
active_theme_kind,
app_icon::{AppIcon, AppIconSettings},
respect_system_theme, AIFontName, AppEditorSettings, CursorBlink, CursorBlinkEnabled,
EnforceMinimumContrast, FocusPaneOnHover, FontSettings, FontSettingsChangedEvent, InputBoxType,
InputModeSettings, InputModeState, MonospaceFontName, PaneSettings, ShouldDimInactivePanes,
ThemeSettings, UIFontName, UseSystemTheme, DEFAULT_MONOSPACE_FONT_NAME, DEFAULT_UI_FONT_NAME,
active_theme_kind, respect_system_theme, AIFontName, AppEditorSettings, CursorBlink,
CursorBlinkEnabled, CursorDisplayType, EnforceMinimumContrast, FocusPaneOnHover, FontSettings,
FontSettingsChangedEvent, GPUSettings, InputBoxType, InputModeSettings, InputModeState,
InputSettings, InputSettingsChangedEvent, MonospaceFontName, PaneSettings,
ShouldDimInactivePanes, ThemeSettings, UseSystemTheme, UseThinStrokes,
DEFAULT_MONOSPACE_FONT_NAME,
};
use crate::settings::{CursorDisplayType, GPUSettings, InputSettings, InputSettingsChangedEvent};
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::blockgrid_element::BlockGridElement;
use crate::terminal::ligature_settings::{LigatureRenderingEnabled, LigatureSettings};
@@ -43,75 +73,31 @@ use crate::terminal::session_settings::SessionSettings;
use crate::terminal::settings::{
AltScreenPadding, AltScreenPaddingMode, Spacing, SpacingMode, TerminalSettings,
};
use crate::terminal::{BlockListSettings, ShowBlockDividers};
use crate::terminal::{ShowJumpToBottomOfBlockButton, SizeInfo};
use crate::themes::theme::{
self, GalaxyTheme, RespectSystemTheme, SelectedSystemThemes, ThemeKind,
use crate::terminal::{
BlockListSettings, ShowBlockDividers, ShowJumpToBottomOfBlockButton, SizeInfo,
};
use crate::user_config::GalaxyConfig;
use crate::themes::theme::{self, RespectSystemTheme, SelectedSystemThemes, ThemeKind, WarpTheme};
use crate::themes::theme_chooser::ThemeChooserMode;
use crate::ui_components::color_dot::{render_color_dot, TAB_COLOR_OPTIONS};
use crate::ui_components::icons::Icon;
use crate::user_config::WarpConfig;
use crate::util::bindings;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::view_components::{Dropdown, DropdownItem, FilterableDropdown};
use crate::window_settings::{
BackgroundBlurRadius, BackgroundBlurTexture, BackgroundOpacity, LeftPanelVisibilityAcrossTabs,
OpenWindowsAtCustomSize, WindowSettings, WindowSettingsChangedEvent, ZoomLevel,
};
use crate::workspace::header_toolbar_editor::HeaderToolbarInlineEditor;
use crate::workspace::tab_settings::{
DirectoryTabColor, PreserveActiveTabColor, ShowCodeReviewButton, ShowIndicatorsButton,
TabCloseButtonPosition, TabSettings, TabSettingsChangedEvent,
UseLatestUserPromptAsConversationTitleInTabNames, UseVerticalTabs,
canonical_directory_key, DirectoryTabColor, HideTitleBarSearchBarInVerticalTabs,
PreserveActiveTabColor, ShowCodeReviewButton, ShowIndicatorsButton,
ShowVerticalTabPanelInRestoredWindows, TabCloseButtonPosition, TabSettings,
TabSettingsChangedEvent, UseLatestUserPromptAsConversationTitleInTabNames, UseVerticalTabs,
WorkspaceDecorationVisibility,
};
use crate::workspace::WorkspaceAction;
use crate::{editor::EditorView, themes::theme_chooser::ThemeChooserMode};
use crate::{
features::FeatureFlag,
view_components::{Dropdown, DropdownItem, FilterableDropdown},
};
use crate::{report_error, report_if_error, themes};
use crate::{send_telemetry_from_ctx, server::telemetry::TelemetryEvent};
use ::settings::{Setting, SettingSection, ToggleableSetting};
use enum_iterator::all;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::path::user_friendly_path;
use galaxyui::elements::{
Clipped, Empty, FormattedTextElement, MainAxisAlignment, MainAxisSize, Text, Wrap,
};
use galaxyui::fonts::{FamilyId, FontInfo, Weight};
use galaxyui::keymap::{ContextPredicate, FixedBinding};
use galaxyui::platform::{Cursor, FilePickerConfiguration, GraphicsBackend};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::radio_buttons::{
RadioButtonItem, RadioButtonLayout, RadioButtonStateHandle,
};
use galaxyui::ui_components::slider::SliderStateHandle;
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::units::IntoPixels;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use galaxyui::id;
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Element, Fill, Flex, Hoverable, MouseStateHandle, ParentElement, Radius,
Shrinkable, DEFAULT_UI_LINE_HEIGHT_RATIO,
},
rendering::ThinStrokes,
};
use galaxyui::{platform::SystemTheme, Action};
use galaxyui::{
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateModel, View,
ViewContext, ViewHandle, WindowId,
};
use crate::settings::UseThinStrokes;
use crate::ui_components::color_dot::{render_color_dot, TAB_COLOR_OPTIONS};
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
use crate::{report_error, report_if_error, send_telemetry_from_ctx, themes};
const FONT_SIZE_INPUT_BOX_WIDTH: f32 = 80.;
const NOTEBOOK_FONT_SIZE_INPUT_BOX_WIDTH: f32 = 50.;
@@ -270,6 +256,50 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
context.to_owned(),
)
.with_group(bindings::BindingGroup::Settings.as_str())]);
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"open new windows with custom size",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleOpenWindowsAtCustomSize,
)),
context,
flags::OPEN_WINDOWS_AT_CUSTOM_SIZE_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"window blur acrylic texture",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleBlurTexture,
)),
context,
flags::WINDOW_BLUR_TEXTURE_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"tools panel visibility across tabs",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleLeftPanelVisibility,
)),
context,
flags::LEFT_PANEL_VISIBILITY_ACROSS_TABS_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"agent font matching terminal font",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleMatchAIToTerminalFontFamily,
)),
context,
flags::MATCH_AI_FONT_TO_TERMINAL_FONT_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"notebook font size matching terminal font size",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleMatchNotebookToMonospaceFontSize,
)),
context,
flags::MATCH_NOTEBOOK_FONT_SIZE_TO_TERMINAL_FONT_SIZE_FLAG,
));
toggle_binding_pairs.push(
ToggleSettingActionPair::new(
@@ -389,6 +419,22 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
context,
flags::USE_VERTICAL_TABS_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"show vertical tabs panel in restored windows",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleShowVerticalTabPanelInRestoredWindows,
)),
context,
flags::SHOW_VERTICAL_TAB_PANEL_IN_RESTORED_WINDOWS_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"latest user prompt as conversation title in tab names",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleUseLatestUserPromptAsConversationTitleInTabNames,
)),
context,
flags::USE_LATEST_USER_PROMPT_AS_CONVERSATION_TITLE_IN_TAB_NAMES_FLAG,
));
}
if FeatureFlag::Ligatures.is_enabled() {
@@ -402,6 +448,24 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
));
}
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"preserve active tab color for new tabs",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::TogglePreserveActiveTabColor,
)),
context,
flags::PRESERVE_ACTIVE_TAB_COLOR_FLAG,
));
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"custom padding in alt-screen",
builder(SettingsAction::AppearancePageToggle(
AppearancePageAction::ToggleAltScreenPadding,
)),
context,
flags::ALT_SCREEN_PADDING_FLAG,
));
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app);
}
@@ -445,6 +509,7 @@ pub enum AppearancePageAction {
},
SetInputType(InputBoxType),
SetAppIcon(AppIcon),
ToggleShowDockIcon,
SetCursorType(CursorDisplayType),
SetWorkspaceDecorationVisibility(WorkspaceDecorationVisibility),
ToggleWorkspaceDecorationVisibility,
@@ -462,6 +527,8 @@ pub enum AppearancePageAction {
ToggleShowCodeReviewButton,
TogglePreserveActiveTabColor,
ToggleVerticalTabs,
ToggleShowVerticalTabPanelInRestoredWindows,
ToggleHideTitleBarSearchBarInVerticalTabs,
ToggleUseLatestUserPromptAsConversationTitleInTabNames,
ToggleLigatureRendering,
ToggleBlurTexture,
@@ -470,6 +537,7 @@ pub enum AppearancePageAction {
OpenUrl(String),
ToggleFocusPaneOnHover,
ToggleInputMode,
ToggleAltScreenPadding,
UpdateAltScreenPaddingMode(AltScreenPaddingMode),
SetTabCloseButtonPosition(TabCloseButtonPosition),
SetZoomLevel(u16),
@@ -592,6 +660,7 @@ impl TypedActionView for AppearanceSettingsPageView {
} => self.set_input_mode(*new_mode, *from_binding, ctx),
SetInputType(input_type) => self.set_input_type(*input_type, ctx),
SetAppIcon(new_icon) => self.set_app_icon(*new_icon, ctx),
ToggleShowDockIcon => self.toggle_show_dock_icon(ctx),
SetCursorType(cursor_display_type) => self.set_cursor_type(*cursor_display_type, ctx),
OpacitySliderDragged(val) => self.set_opacity(*val, false, ctx),
BlurSliderDragged(val) => self.set_blur(*val, false, ctx),
@@ -602,6 +671,12 @@ impl TypedActionView for AppearanceSettingsPageView {
ToggleShowCodeReviewButton => self.toggle_show_code_review_button(ctx),
TogglePreserveActiveTabColor => self.toggle_preserve_active_tab_color(ctx),
ToggleVerticalTabs => self.toggle_vertical_tabs(ctx),
ToggleShowVerticalTabPanelInRestoredWindows => {
self.toggle_show_vertical_tab_panel_in_restored_windows(ctx)
}
ToggleHideTitleBarSearchBarInVerticalTabs => {
self.toggle_hide_title_bar_search_bar_in_vertical_tabs(ctx)
}
ToggleUseLatestUserPromptAsConversationTitleInTabNames => {
self.toggle_use_latest_user_prompt_as_conversation_title_in_tab_names(ctx)
}
@@ -628,6 +703,19 @@ impl TypedActionView for AppearanceSettingsPageView {
ToggleInputMode => {
self.toggle_input_mode(ctx);
}
ToggleAltScreenPadding => {
let new_mode = TerminalSettings::as_ref(ctx).alt_screen_padding.toggled();
TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| {
report_if_error!(terminal_settings
.alt_screen_padding
.set_value(new_mode, ctx));
});
self.set_alt_screen_padding_editor_text(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::UpdateAltScreenPaddingMode { new_mode },
ctx
);
}
UpdateAltScreenPaddingMode(new_mode) => {
TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| {
report_if_error!(terminal_settings
@@ -1402,6 +1490,12 @@ impl AppearanceSettingsPageView {
if FeatureFlag::VerticalTabs.is_enabled() {
tab_settings_widgets.push(Box::new(VerticalTabsWidget::default()));
tab_settings_widgets.push(Box::new(
ShowVerticalTabPanelInRestoredWindowsWidget::default(),
));
tab_settings_widgets.push(Box::new(
HideTitleBarSearchBarInVerticalTabsWidget::default(),
));
tab_settings_widgets.push(Box::new(
UseLatestUserPromptAsConversationTitleInTabNamesWidget::default(),
));
@@ -1468,6 +1562,11 @@ impl AppearanceSettingsPageView {
editor.set_buffer_text(&format!("{line_height_ratio}"), ctx);
});
}
AppearanceEvent::ThemeChanged => {
// Context-chip colors are theme-derived, so rebuild the Input
// preview chips when the theme changes to keep them in sync.
self.context_chips = Self::get_context_chip_renderers(ctx);
}
_ => {}
}
@@ -1519,7 +1618,7 @@ impl AppearanceSettingsPageView {
// If we're on a non-Linux platform, render the dropdown item in the
// actual font. We currently don't do this on Linux because
// pre-loading all of the fonts is too expensive.
if cfg!(not(target_os = "linux")) {
if cfg!(not(any(target_os = "linux", target_os = "freebsd"))) {
if let Some(family_id) = ctx.font_cache().family_id_for_name(&font_name) {
initial_dropdown_item = initial_dropdown_item.with_font_override(family_id);
}
@@ -1926,7 +2025,7 @@ impl AppearanceSettingsPageView {
// If we're on a non-Linux platform, render the dropdown item in the
// actual font. We currently don't do this on Linux because
// pre-loading all of the fonts is too expensive.
if cfg!(not(target_os = "linux")) {
if cfg!(not(any(target_os = "linux", target_os = "freebsd"))) {
if let Some(family_id) = family {
dropdown = dropdown.with_font_override(*family_id)
}
@@ -1987,7 +2086,7 @@ impl AppearanceSettingsPageView {
// If we're on a non-Linux platform, render the dropdown item in the
// actual font. We currently don't do this on Linux because
// pre-loading all of the fonts is too expensive.
if cfg!(not(target_os = "linux")) {
if cfg!(not(any(target_os = "linux", target_os = "freebsd"))) {
if let Some(family_id) = family {
dropdown = dropdown.with_font_override(*family_id)
}
@@ -2359,6 +2458,12 @@ impl AppearanceSettingsPageView {
});
}
fn toggle_show_dock_icon(&mut self, ctx: &mut ViewContext<Self>) {
AppIconSettings::handle(ctx).update(ctx, |app_icon_settings, ctx| {
report_if_error!(app_icon_settings.show_dock_icon.toggle_and_save_value(ctx));
});
}
fn set_cursor_type(&mut self, new_cursor_type: CursorDisplayType, ctx: &mut ViewContext<Self>) {
AppEditorSettings::handle(ctx).update(ctx, |app_editor_settings, ctx| {
report_if_error!(app_editor_settings
@@ -2428,6 +2533,22 @@ impl AppearanceSettingsPageView {
});
}
fn toggle_show_vertical_tab_panel_in_restored_windows(&mut self, ctx: &mut ViewContext<Self>) {
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.show_vertical_tab_panel_in_restored_windows
.toggle_and_save_value(ctx));
});
}
fn toggle_hide_title_bar_search_bar_in_vertical_tabs(&mut self, ctx: &mut ViewContext<Self>) {
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.hide_title_bar_search_bar_in_vertical_tabs
.toggle_and_save_value(ctx));
});
}
fn toggle_use_latest_user_prompt_as_conversation_title_in_tab_names(
&mut self,
ctx: &mut ViewContext<Self>,
@@ -2906,34 +3027,31 @@ impl SettingsWidget for ThemeSelectWidget {
}
#[derive(Default)]
struct CustomAppIconWidget {}
struct CustomAppIconWidget {
show_dock_icon_switch_state: SwitchStateHandle,
}
impl SettingsWidget for CustomAppIconWidget {
type View = AppearanceSettingsPageView;
fn search_terms(&self) -> &str {
"customize custom app icon icons"
"customize custom app icon icons dock cmd tab app switcher"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
app: &AppContext,
) -> Box<dyn Element> {
#[allow(unused_mut)]
let show_bundle_warning = {
#[cfg(target_os = "macos")]
#[allow(deprecated)]
{
use cocoa::base::id;
use objc::{class, msg_send, sel, sel_impl};
unsafe {
let running_app: id =
msg_send![class!(NSRunningApplication), currentApplication];
let bundle_id: id = msg_send![running_app, bundleIdentifier];
bundle_id.is_null()
}
use objc2_app_kit::NSRunningApplication;
NSRunningApplication::currentApplication()
.bundleIdentifier()
.is_none()
}
#[cfg(not(target_os = "macos"))]
{
@@ -2951,41 +3069,76 @@ impl SettingsWidget for CustomAppIconWidget {
&view.app_icon_dropdown,
);
let show_dock_icon_toggle = render_body_item::<AppearancePageAction>(
"Show Warp in Dock".into(),
None,
LocalOnlyIconState::for_setting(
ShowDockIconState::storage_key(),
ShowDockIconState::sync_to_cloud(),
&mut view.local_only_icon_tooltip_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.show_dock_icon_switch_state.clone())
.check(*AppIconSettings::as_ref(app).show_dock_icon)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AppearancePageAction::ToggleShowDockIcon);
})
.finish(),
None,
);
let show_dock_icon_is_supported = AppIconSettings::as_ref(app)
.show_dock_icon
.is_supported_on_current_platform();
#[cfg(target_os = "macos")]
{
use crate::appearance::AppearanceManager;
let app_icon_at_startup = AppearanceManager::as_ref(_app).app_icon_at_startup();
let current_icon = *AppIconSettings::as_ref(_app).app_icon;
if current_icon == AppIcon::Galaxy
let app_icon_at_startup = AppearanceManager::as_ref(app).app_icon_at_startup();
let current_icon = *AppIconSettings::as_ref(app).app_icon;
if current_icon == AppIcon::Default
&& ChannelState::channel() != Channel::Local
&& app_icon_at_startup != AppIcon::Galaxy
{
let theme = appearance.theme();
return Flex::column()
.with_child(dropdown)
.with_child(
appearance
.ui_builder()
.wrappable_text(
"You may need to restart Galaxy for MacOS to apply the preferred icon style.",
true,
)
.with_style(UiComponentStyles {
font_color: Some(
theme.sub_text_color(theme.background()).into_solid(),
),
margin: Some(Coords::default().bottom(8.)),
..Default::default()
})
.build()
.finish(),
)
.finish();
let column = Flex::column().with_child(dropdown).with_child(
appearance
.ui_builder()
.wrappable_text(
"You may need to restart Warp for MacOS to apply the preferred icon style.",
true,
)
.with_style(UiComponentStyles {
font_color: Some(
theme.sub_text_color(theme.background()).into_solid(),
),
margin: Some(Coords::default().bottom(8.)),
..Default::default()
})
.build()
.finish(),
);
let column = if show_dock_icon_is_supported {
column.with_child(show_dock_icon_toggle)
} else {
column
};
return column.finish();
}
}
dropdown
let column = Flex::column().with_child(dropdown);
let column = if show_dock_icon_is_supported {
column.with_child(show_dock_icon_toggle)
} else {
column
};
column.finish()
}
}
@@ -4803,6 +4956,106 @@ impl SettingsWidget for VerticalTabsWidget {
}
}
#[derive(Default)]
struct ShowVerticalTabPanelInRestoredWindowsWidget {
switch_state: SwitchStateHandle,
}
impl SettingsWidget for ShowVerticalTabPanelInRestoredWindowsWidget {
type View = AppearanceSettingsPageView;
fn search_terms(&self) -> &str {
"vertical tabs panel restore window session snapshot"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let tab_settings = TabSettings::as_ref(app);
render_body_item::<AppearancePageAction>(
"Show vertical tabs panel in restored windows".into(),
None,
LocalOnlyIconState::for_setting(
ShowVerticalTabPanelInRestoredWindows::storage_key(),
ShowVerticalTabPanelInRestoredWindows::sync_to_cloud(),
&mut view.local_only_icon_tooltip_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.switch_state.clone())
.check(*tab_settings.show_vertical_tab_panel_in_restored_windows)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
AppearancePageAction::ToggleShowVerticalTabPanelInRestoredWindows,
);
})
.finish(),
Some(
"When enabled, reopening or restoring a window opens the vertical tabs panel even if it was closed when the window was last saved."
.to_string(),
),
)
}
}
#[derive(Default)]
struct HideTitleBarSearchBarInVerticalTabsWidget {
switch_state: SwitchStateHandle,
}
impl SettingsWidget for HideTitleBarSearchBarInVerticalTabsWidget {
type View = AppearanceSettingsPageView;
fn search_terms(&self) -> &str {
"hide title bar search bar vertical tabs chrome minimal"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let tab_settings = TabSettings::as_ref(app);
render_body_item::<AppearancePageAction>(
"Hide search bar in vertical tab layout".into(),
None,
LocalOnlyIconState::for_setting(
HideTitleBarSearchBarInVerticalTabs::storage_key(),
HideTitleBarSearchBarInVerticalTabs::sync_to_cloud(),
&mut view.local_only_icon_tooltip_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.switch_state.clone())
.check(*tab_settings.hide_title_bar_search_bar_in_vertical_tabs)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
AppearancePageAction::ToggleHideTitleBarSearchBarInVerticalTabs,
);
})
.finish(),
Some(
"When using the vertical tab layout, hide the search bar in the title bar. Search stays available via the command palette and keyboard shortcuts."
.to_string(),
),
)
}
}
#[derive(Default)]
struct UseLatestUserPromptAsConversationTitleInTabNamesWidget {
switch_state: SwitchStateHandle,
@@ -4918,8 +5171,7 @@ fn build_directory_delete_buttons(
fn add_directory_tab_color_path(path: PathBuf, ctx: &mut ViewContext<AppearanceSettingsPageView>) {
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let current = settings.directory_tab_colors.value();
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
let key = canonical.to_string_lossy().to_string();
let key = canonical_directory_key(&path);
let dominated_by_existing = current
.0
.get(&key)
@@ -0,0 +1,421 @@
use std::cell::RefCell;
use std::collections::HashMap;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use thousands::Separable;
use galaxy_core::ui::appearance::Appearance;
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;
@@ -0,0 +1,376 @@
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"
);
}
@@ -0,0 +1,838 @@
use std::collections::HashMap;
use itertools::Itertools as _;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::channel::ChannelState;
use galaxy_core::ui::appearance::Appearance;
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;
@@ -0,0 +1,139 @@
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);
}
@@ -0,0 +1,801 @@
use chrono::{DateTime, Datelike, Local, Utc};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::appearance::Appearance;
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")
)
}
}
@@ -0,0 +1,371 @@
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::appearance::Appearance;
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;
@@ -0,0 +1,95 @@
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,3 +1,7 @@
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,20 +1,15 @@
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::{
elements::{Align, Clipped},
ui_components::components::{Coords, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
Appearance,
};
use galaxyui::{
elements::{
Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
MouseStateHandle, Padding, ParentElement, Radius, Text,
},
ui_components::{button::ButtonVariant, components::UiComponent},
};
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use crate::Appearance;
const MAXIMUM_SPENDING_LIMIT_CENTS: u32 = 999999999;
@@ -1,23 +1,20 @@
use crate::{
ai::blocklist::format_credits,
ai::blocklist::usage::conversation_usage_view::{
ConversationUsageInfo, ConversationUsageView, DisplayMode,
},
settings_view::billing_and_usage_page::BillingAndUsagePageAction,
ui_components::{blended_colors, icons::Icon},
};
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,
},
platform::Cursor,
AppContext, Element, View,
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
@@ -111,8 +108,10 @@ impl UsageHistoryEntry {
))
.finish();
let total_credits =
entry.usage_metadata.credits_spent + entry.usage_metadata.platform_credits_spent;
let credits_spent = Text::new_inline(
format_credits(entry.usage_metadata.credits_spent as f32),
format_credits(total_credits as f32),
appearance.ui_font_family(),
14.,
)
@@ -1,16 +1,17 @@
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::{auth::AuthClient, ServerApiProvider};
use galaxy_graphql::scalars::Time;
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::ServerApiProvider;
const PAGE_SIZE: i32 = 20;
pub struct UsageHistoryModel {
auth_client: Arc<dyn AuthClient>,
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.
@@ -25,9 +26,9 @@ impl SingletonEntity for UsageHistoryModel {}
impl UsageHistoryModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
Self {
auth_client,
ai_client,
entries: Vec::new(),
is_loading: false,
has_more_entries: true,
@@ -96,7 +97,7 @@ impl UsageHistoryModel {
) {
// 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 auth_client = self.auth_client.clone();
let ai_client = self.ai_client.clone();
if is_initial_load {
self.is_loading = true;
@@ -105,7 +106,7 @@ impl UsageHistoryModel {
ctx.spawn(
async move {
auth_client
ai_client
.get_conversation_usage_history(
Some(30),
Some(limit),
@@ -0,0 +1,167 @@
//! 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()
}
}
@@ -0,0 +1,48 @@
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
)));
}
+161 -235
View File
@@ -1,84 +1,69 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Local;
use galaxy_core::ui::theme::Fill;
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
use galaxy_graphql::billing::AddonCreditsOption;
use galaxyui::prelude::ChildView;
use galaxyui::{
elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkUrl,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Text, Wrap,
},
fonts::{Properties, Weight},
platform::Cursor,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateView, View,
ViewContext, ViewHandle,
};
use itertools::Itertools;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use thousands::Separable;
use settings::Setting;
use crate::{
ai::AIRequestUsageModel,
auth::{
auth_manager::LoginGatedFeature, auth_state::AuthState, auth_view_modal::AuthViewVariant,
AuthManager, AuthStateProvider, UserUid,
},
menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields},
modal::{Modal, ModalEvent, ModalViewState},
pricing::{PricingInfoModel, PricingInfoModelEvent},
send_telemetry_from_ctx,
server::{ids::ServerId, telemetry::TelemetryEvent},
settings::ai::AISettings,
settings_view::settings_page::TOGGLE_BUTTON_RIGHT_PADDING,
ui_components::{
blended_colors,
buttons::icon_button,
icons::Icon,
menu_button::{icon_button_with_context_menu, MenuDirection},
tab_selector::{self, SettingsTab},
},
view_components::{
action_button::{ActionButton, PrimaryTheme, SecondaryTheme},
ToastFlavor,
},
workspaces::{
team::Team,
update_manager::TeamUpdateManager,
user_profiles::UserProfiles,
user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
workspace::{CustomerType, Workspace},
},
WorkspaceAction,
use thousands::Separable;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxy_graphql::billing::AddonCreditsOption;
use galaxyui::elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkUrl, MainAxisAlignment,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Shrinkable, Text, Wrap,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::prelude::ChildView;
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateView, View,
ViewContext, ViewHandle,
};
use super::{
admin_actions::AdminActions,
billing_and_usage::{
overage_limit_modal::{SpendingLimitModal, SpendingLimitModalEvent},
usage_history_entry::UsageHistoryEntry,
usage_history_model::UsageHistoryModel,
},
settings_page::{
build_sub_header, render_body_item, render_customer_type_badge, render_info_icon,
AdditionalInfo, Category, PageType, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, HEADER_PADDING,
},
MatchData, SettingsSection,
use super::admin_actions::AdminActions;
use super::billing_and_usage::overage_limit_modal::{SpendingLimitModal, SpendingLimitModalEvent};
use super::billing_and_usage::usage_history_entry::UsageHistoryEntry;
use super::billing_and_usage::usage_history_model::UsageHistoryModel;
use super::settings_page::{
build_sub_header, render_body_item, render_customer_type_badge, render_info_icon,
AdditionalInfo, HEADER_PADDING,
};
use super::SettingsSection;
use crate::ai::AIRequestUsageModel;
use crate::auth::auth_manager::LoginGatedFeature;
use crate::auth::auth_state::AuthState;
use crate::auth::auth_view_modal::AuthViewVariant;
use crate::auth::{AuthManager, AuthStateProvider, UserUid};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::modal::{Modal, ModalEvent, ModalViewState};
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::server::ids::ServerId;
use crate::server::telemetry::TelemetryEvent;
use crate::settings::ai::AISettings;
use crate::settings_view::settings_page::TOGGLE_BUTTON_RIGHT_PADDING;
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
use crate::ui_components::tab_selector::{self, SettingsTab};
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
use crate::view_components::ToastFlavor;
use crate::workspaces::team::Team;
use crate::workspaces::update_manager::TeamUpdateManager;
use crate::workspaces::user_profiles::UserProfiles;
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::{CustomerType, Workspace};
use crate::{send_telemetry_from_ctx, WorkspaceAction};
const HEADER_FONT_SIZE: f32 = 16.;
const OVERAGE_USAGE_LINK_TEXT: &str = "View details on overage usage";
@@ -199,7 +184,6 @@ pub(crate) struct ProratedRequestLimitsInfo {
}
pub struct BillingAndUsagePageView {
page: PageType<Self>,
auth_state: Arc<AuthState>,
overage_limit_modal_state: ModalViewState<Modal<SpendingLimitModal>>,
addon_credit_modal_state: ModalViewState<Modal<SpendingLimitModal>>,
@@ -230,6 +214,32 @@ pub struct BillingAndUsagePageView {
addon_credit_denomination_buttons: Vec<ViewHandle<ActionButton>>,
purchase_addon_credits_loading: bool,
prorated_request_limits_info_mouse_states: Vec<MouseStateHandle>,
// ── Plan-header mouse states ─────────────────────────────────────────
upgrade_link: MouseStateHandle,
anonymous_user_sign_up_button: MouseStateHandle,
enterprise_contact_us_link: MouseStateHandle,
stripe_billing_portal_link: MouseStateHandle,
admin_panel_link: MouseStateHandle,
// ── Page-body mouse / switch states ──────────────────────────────────
requests_highlight_index: HighlightedHyperlink,
ubp_switch_state: SwitchStateHandle,
ubp_info_icon_mouse_state: MouseStateHandle,
pencil_icon_mouse_state: MouseStateHandle,
overage_usage_link_mouse_state: MouseStateHandle,
// Mouse state for the inline "Increase your limit" link inside the warning row
exceed_limit_link_mouse_state: MouseStateHandle,
refresh_icon_mouse_state: MouseStateHandle,
sort_icon_mouse_state: MouseStateHandle,
overview_tab_mouse_state: MouseStateHandle,
usage_history_tab_mouse_state: MouseStateHandle,
addon_info_icon_mouse_state: MouseStateHandle,
edit_monthly_limit: MouseStateHandle,
auto_reload_switch: SwitchStateHandle,
buy_button: MouseStateHandle,
// Ambient agent trial widget buttons.
ambient_trial_new_agent_button: MouseStateHandle,
ambient_trial_buy_more_button: MouseStateHandle,
ambient_trial_dismiss_button: MouseStateHandle,
}
impl BillingAndUsagePageView {
@@ -336,7 +346,6 @@ impl BillingAndUsagePageView {
});
let mut me = Self {
page: Self::build_page(),
auth_state,
overage_limit_modal_state: ModalViewState::new(overage_limit_modal_view),
addon_credit_modal_state: ModalViewState::new(addon_credit_modal_view),
@@ -357,6 +366,28 @@ impl BillingAndUsagePageView {
addon_credit_denomination_buttons: Default::default(),
purchase_addon_credits_loading: false,
prorated_request_limits_info_mouse_states: Default::default(),
upgrade_link: MouseStateHandle::default(),
anonymous_user_sign_up_button: MouseStateHandle::default(),
enterprise_contact_us_link: MouseStateHandle::default(),
stripe_billing_portal_link: MouseStateHandle::default(),
admin_panel_link: MouseStateHandle::default(),
requests_highlight_index: HighlightedHyperlink::default(),
ubp_switch_state: SwitchStateHandle::default(),
ubp_info_icon_mouse_state: MouseStateHandle::default(),
pencil_icon_mouse_state: MouseStateHandle::default(),
overage_usage_link_mouse_state: MouseStateHandle::default(),
exceed_limit_link_mouse_state: MouseStateHandle::default(),
refresh_icon_mouse_state: MouseStateHandle::default(),
sort_icon_mouse_state: MouseStateHandle::default(),
overview_tab_mouse_state: MouseStateHandle::default(),
usage_history_tab_mouse_state: MouseStateHandle::default(),
addon_info_icon_mouse_state: MouseStateHandle::default(),
edit_monthly_limit: MouseStateHandle::default(),
auto_reload_switch: SwitchStateHandle::default(),
buy_button: MouseStateHandle::default(),
ambient_trial_new_agent_button: MouseStateHandle::default(),
ambient_trial_buy_more_button: MouseStateHandle::default(),
ambient_trial_dismiss_button: MouseStateHandle::default(),
};
me.update_addon_credits_options(ctx);
me.refresh_addon_credits_settings(ctx);
@@ -364,18 +395,6 @@ impl BillingAndUsagePageView {
me
}
fn build_page() -> PageType<Self> {
let categories = vec![Category::new(
"Billing and usage",
vec![
Box::new(PlanWidget::default()),
Box::new(UsageWidget::default()),
],
)];
PageType::new_categorized(categories, None)
}
fn refresh_addon_credits_settings(&mut self, ctx: &mut ViewContext<Self>) {
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else {
return;
@@ -680,20 +699,12 @@ impl BillingAndUsagePageView {
}
}
impl SettingsPageMeta for BillingAndUsagePageView {
fn section() -> SettingsSection {
SettingsSection::BillingAndUsage
}
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>) {
impl BillingAndUsagePageView {
pub(super) fn on_page_selected(
&mut self,
_allow_steal_focus: bool,
ctx: &mut ViewContext<Self>,
) {
self.purchase_addon_credits_loading = false;
std::mem::drop(
TeamUpdateManager::handle(ctx)
@@ -709,18 +720,6 @@ impl SettingsPageMeta for BillingAndUsagePageView {
self.refresh_addon_credits_settings(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();
}
}
#[derive(Debug, Clone)]
@@ -744,7 +743,11 @@ impl View for BillingAndUsagePageView {
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
let appearance = Appearance::as_ref(app);
Flex::column()
.with_child(self.render_plan_header(appearance, app))
.with_child(self.render_page_body(appearance, app))
.finish()
}
}
@@ -1010,12 +1013,6 @@ impl TypedActionView for BillingAndUsagePageView {
}
}
impl From<ViewHandle<BillingAndUsagePageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<BillingAndUsagePageView>) -> Self {
SettingsPageViewHandle::BillingAndUsage(view_handle)
}
}
#[derive(Debug, Clone)]
pub enum BillingAndUsagePageAction {
OpenUrl(HyperlinkUrl),
@@ -1074,7 +1071,6 @@ impl BillingAndUsagePageAction {
impl From<&BillingAndUsagePageAction> for LoginGatedFeature {
fn from(val: &BillingAndUsagePageAction) -> LoginGatedFeature {
use BillingAndUsagePageAction::*;
match val {
Upgrade { .. } => "Upgrade Plan",
GenerateStripeBillingPortalLink { .. } => "Generate Stripe Billing Portal Link",
@@ -1083,36 +1079,13 @@ impl From<&BillingAndUsagePageAction> for LoginGatedFeature {
}
}
#[derive(Default)]
struct UsageWidget {
requests_highlight_index: HighlightedHyperlink,
ubp_switch_state: SwitchStateHandle,
ubp_info_icon_mouse_state: MouseStateHandle,
pencil_icon_mouse_state: MouseStateHandle,
overage_usage_link_mouse_state: MouseStateHandle,
// Mouse state for the inline "Increase your limit" link inside the warning row
exceed_limit_link_mouse_state: MouseStateHandle,
refresh_icon_mouse_state: MouseStateHandle,
sort_icon_mouse_state: MouseStateHandle,
overview_tab_mouse_state: MouseStateHandle,
usage_history_tab_mouse_state: MouseStateHandle,
addon_info_icon_mouse_state: MouseStateHandle,
edit_monthly_limit: MouseStateHandle,
auto_reload_switch: SwitchStateHandle,
buy_button: MouseStateHandle,
// Ambient agent trial widget buttons.
ambient_trial_new_agent_button: MouseStateHandle,
ambient_trial_buy_more_button: MouseStateHandle,
ambient_trial_dismiss_button: MouseStateHandle,
}
#[derive(Copy, Clone, Debug)]
enum Divisor {
Unlimited,
Limit(usize),
}
impl UsageWidget {
impl BillingAndUsagePageView {
/// Renders the ambient agent trial widget showing remaining credits and action buttons.
/// Returns None if the user has no ambient-only credits (None value from server),
/// or if the widget has been dismissed (only dismissible when below threshold).
@@ -2456,23 +2429,11 @@ impl UsageWidget {
}
}
impl SettingsWidget for UsageWidget {
type View = BillingAndUsagePageView;
fn search_terms(&self) -> &str {
"a.i. ai usage limit plan"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
impl BillingAndUsagePageView {
fn render_page_body(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let ai_request_usage_model = AIRequestUsageModel::as_ref(app);
let next_refresh_time = ai_request_usage_model.next_refresh_time();
let local_next_refresh_time = next_refresh_time.with_timezone(&Local);
let formatted_next_refresh_time = local_next_refresh_time
let formatted_next_refresh_time = ai_request_usage_model
.next_refresh_time_local()
.format("%b %d at %-I:%M %p")
.to_string();
let workspace_is_delinquent_due_to_payment_issue = UserWorkspaces::as_ref(app)
@@ -2495,7 +2456,7 @@ impl SettingsWidget for UsageWidget {
let tab_selector = tab_selector::render_tab_selector(
tabs,
view.selected_tab.label(),
self.selected_tab.label(),
// On click, set clicked tab as selected
|label, ctx| {
ctx.dispatch_typed_action(BillingAndUsagePageAction::SelectTab(
@@ -2507,33 +2468,32 @@ impl SettingsWidget for UsageWidget {
usage.add_child(tab_selector);
// Render correct page based on selected tab
if view.selected_tab == BillingUsageTab::Overview {
if self.selected_tab == BillingUsageTab::Overview {
let prorated_mouse_states = self.prorated_request_limits_info_mouse_states.clone();
let usage_content = self.render_usage_content(
view,
appearance,
app,
ai_request_usage_model,
&formatted_next_refresh_time,
workspace_is_delinquent_due_to_payment_issue,
&view.prorated_request_limits_info_mouse_states,
&prorated_mouse_states,
);
usage.add_child(usage_content);
} else {
usage.add_child(self.render_usage_history_content(view, appearance, app));
usage.add_child(self.render_usage_history_content(appearance, app));
}
usage.finish()
}
}
impl UsageWidget {
impl BillingAndUsagePageView {
fn render_usage_history_content(
&self,
view: &BillingAndUsagePageView,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let usage_history = view.usage_history_model.as_ref(app);
let usage_history = self.usage_history_model.as_ref(app);
if usage_history.entries().is_empty() {
return self.render_empty_usage_history_content(
usage_history.is_loading(),
@@ -2561,20 +2521,20 @@ impl UsageWidget {
let mut usage_history_list = Flex::column().with_spacing(8.);
let entries = usage_history.entries();
for entry in entries.iter() {
let is_expanded = view
let is_expanded = self
.expanded_usage_entries
.get(&entry.conversation_id)
.copied()
.unwrap_or(false);
let mouse_state = view
let mouse_state = self
.usage_entries_mouse_states
.borrow_mut()
.entry(entry.conversation_id.clone())
.or_default()
.clone();
let tooltip_mouse_state = view
let tooltip_mouse_state = self
.usage_entries_tooltip_mouse_states
.borrow_mut()
.entry(entry.conversation_id.clone())
@@ -2597,7 +2557,7 @@ impl UsageWidget {
content.add_child(usage_history_list.finish());
if usage_history.has_more_entries() {
let load_more = view.load_more_button.as_ref(app).render(app);
let load_more = self.load_more_button.as_ref(app).render(app);
content.add_child(
Container::new(
Flex::row()
@@ -2790,7 +2750,6 @@ impl UsageWidget {
#[allow(clippy::too_many_arguments)]
fn render_usage_content(
&self,
view: &BillingAndUsagePageView,
appearance: &Appearance,
app: &AppContext,
ai_request_usage_model: &AIRequestUsageModel,
@@ -2857,8 +2816,8 @@ impl UsageWidget {
ctx.dispatch_typed_action(BillingAndUsagePageAction::ToggleSortingMenu)
},
self.sort_icon_mouse_state.clone(),
&view.sorting_menu,
view.sorting_menu_open,
&self.sorting_menu,
self.sorting_menu_open,
MenuDirection::Right,
Some(Cursor::PointingHand),
None,
@@ -2910,14 +2869,14 @@ impl UsageWidget {
if !is_enterprise_payg_with_zero_credits {
usage.add_child(self.render_addon_credits_panel(
view.selected_addon_denomination,
self.selected_addon_denomination,
workspace,
team.uid,
has_admin_permissions,
bonus_credit_balance,
&view.addon_credits_options,
&view.addon_credit_denomination_buttons,
view.purchase_addon_credits_loading,
&self.addon_credits_options,
&self.addon_credit_denomination_buttons,
self.purchase_addon_credits_loading,
workspace_is_delinquent_due_to_payment_issue,
app,
));
@@ -3091,8 +3050,8 @@ impl UsageWidget {
sort_user_items_in_place(
&mut user_information,
&current_user_display_name,
view.current_sort_key,
view.current_sort_order,
self.current_sort_key,
self.current_sort_order,
);
let user_information = user_information
@@ -3187,7 +3146,9 @@ impl UsageWidget {
" for security features like SSO and automatically applied zero data retention.",
),
]
} else if team.billing_metadata.is_on_build_business_plan() {
} else if team.billing_metadata.is_on_build_business_plan()
|| team.billing_metadata.is_on_legacy_business_plan()
{
vec![
FormattedTextFragment::hyperlink(
"Upgrade to Enterprise",
@@ -3210,7 +3171,7 @@ impl UsageWidget {
"Upgrade to the Build plan",
upgrade_url,
)];
if UserWorkspaces::as_ref(app).is_byo_api_key_enabled() {
if UserWorkspaces::as_ref(app).is_byo_api_key_enabled(app) {
fragments.push(FormattedTextFragment::plain_text(" or "));
fragments.push(FormattedTextFragment::hyperlink_action(
"bring your own key",
@@ -3269,7 +3230,7 @@ impl UsageWidget {
if team.billing_metadata.is_usage_based_pricing_toggleable() {
let usage_based_pricing_settings = workspaces.usage_based_pricing_settings();
let enabled = view
let enabled = self
.usage_based_pricing_toggle_override
.unwrap_or(usage_based_pricing_settings.enabled);
@@ -3280,7 +3241,7 @@ impl UsageWidget {
appearance,
app,
has_admin_permissions,
view.usage_based_pricing_toggle_loading,
self.usage_based_pricing_toggle_loading,
))
.with_margin_bottom(16.)
.finish(),
@@ -3354,21 +3315,7 @@ pub(crate) fn sort_user_items_in_place<T>(
});
}
#[derive(Default)]
struct PlanWidgetStateHandles {
upgrade_link: MouseStateHandle,
anonymous_user_sign_up_button: MouseStateHandle,
enterprise_contact_us_link: MouseStateHandle,
stripe_billing_portal_link: MouseStateHandle,
admin_panel_link: MouseStateHandle,
}
#[derive(Default)]
struct PlanWidget {
ui_state_handles: PlanWidgetStateHandles,
}
impl PlanWidget {
impl BillingAndUsagePageView {
fn render_anonymous_account_info(
&self,
auth_state: &AuthState,
@@ -3391,7 +3338,7 @@ impl PlanWidget {
.ui_builder()
.button(
ButtonVariant::Accent,
self.ui_state_handles.anonymous_user_sign_up_button.clone(),
self.anonymous_user_sign_up_button.clone(),
)
.with_style(button_styles)
.with_text_label("Sign up".to_owned())
@@ -3411,10 +3358,7 @@ impl PlanWidget {
Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Link,
self.ui_state_handles.upgrade_link.clone(),
)
.button(ButtonVariant::Link, self.upgrade_link.clone())
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::IconFirst,
@@ -3479,10 +3423,7 @@ impl PlanWidget {
let content = Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Link,
self.ui_state_handles.enterprise_contact_us_link.clone(),
)
.button(ButtonVariant::Link, self.enterprise_contact_us_link.clone())
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::IconFirst,
@@ -3540,10 +3481,7 @@ impl PlanWidget {
Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Link,
self.ui_state_handles.stripe_billing_portal_link.clone(),
)
.button(ButtonVariant::Link, self.stripe_billing_portal_link.clone())
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::IconFirst,
@@ -3581,10 +3519,7 @@ impl PlanWidget {
let compare_plans_button = Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Link,
self.ui_state_handles.admin_panel_link.clone(),
)
.button(ButtonVariant::Link, self.admin_panel_link.clone())
.with_text_and_icon_label(
TextAndIcon::new(
TextAndIconAlignment::IconFirst,
@@ -3646,8 +3581,10 @@ impl PlanWidget {
right_side.add_child(admin_actions);
}
let admin_panel_button = self.render_admin_panel_button(team.uid, appearance);
right_side.add_child(admin_panel_button);
if team.billing_metadata.is_enterprise_plan() {
let admin_panel_button = self.render_admin_panel_button(team.uid, appearance);
right_side.add_child(admin_panel_button);
}
}
} else {
let (plan_badge, compare_plans_button) =
@@ -3661,23 +3598,12 @@ impl PlanWidget {
}
}
impl SettingsWidget for PlanWidget {
type View = BillingAndUsagePageView;
fn search_terms(&self) -> &str {
"plan billing"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let account_info = if view.auth_state.is_anonymous_or_logged_out() {
self.render_anonymous_account_info(view.auth_state.as_ref(), appearance)
impl BillingAndUsagePageView {
fn render_plan_header(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let account_info = if self.auth_state.is_anonymous_or_logged_out() {
self.render_anonymous_account_info(self.auth_state.as_ref(), appearance)
} else {
self.render_account_info(view.auth_state.as_ref(), app, appearance)
self.render_account_info(self.auth_state.as_ref(), app, appearance)
};
let mut col = Flex::column();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
use remote_server::codebase_index_proto::{RemoteCodebaseIndexState, RemoteCodebaseIndexStatus};
use super::remote_codebase_index_limit_reached;
fn remote_status_with_failure(failure_message: Option<&str>) -> RemoteCodebaseIndexStatus {
RemoteCodebaseIndexStatus {
repo_path: "/workspaces/repo".to_string(),
state: RemoteCodebaseIndexState::Unavailable,
last_updated_epoch_millis: Some(1),
progress_completed: None,
progress_total: None,
failure_message: failure_message.map(ToOwned::to_owned),
root_hash: None,
}
}
#[test]
fn remote_index_limit_failure_is_detected_from_status_message() {
let status = remote_status_with_failure(Some(
"Cannot index remote codebase because the maximum number of codebase indexes has been reached.",
));
assert!(remote_codebase_index_limit_reached(&status));
}
#[test]
fn other_unavailable_failures_are_not_index_limit_failures() {
let status = remote_status_with_failure(Some(
"Cannot index remote codebase because indexing did not start.",
));
assert!(!remote_codebase_index_limit_reached(&status));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,401 @@
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
));
}
+429
View File
@@ -0,0 +1,429 @@
#[cfg(feature = "local_fs")]
use std::path::PathBuf;
use warpui::elements::{
ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::{AppContext, Element, Entity, SingletonEntity, View, ViewContext, ViewHandle};
use crate::ai::custom_model_routers::{CustomModelRouter, CustomModelRouting};
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::appearance::Appearance;
use crate::settings::AISettings;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::ActionButton;
#[cfg(feature = "local_fs")]
use crate::view_components::action_button::{ButtonSize, DangerSecondaryTheme, SecondaryTheme};
#[cfg(feature = "local_fs")]
const HEADER_BUTTON_HEIGHT: f32 = 28.;
#[cfg(feature = "local_fs")]
#[derive(Debug, Clone)]
pub enum CustomRouterViewAction {
OpenFile,
Edit,
Delete,
}
pub enum CustomRouterViewEvent {
#[cfg(feature = "local_fs")]
OpenFile(PathBuf),
#[cfg(feature = "local_fs")]
Edit,
#[cfg(feature = "local_fs")]
Delete,
}
pub struct CustomRouterView {
router: CustomModelRouter,
open_file_button: ViewHandle<ActionButton>,
edit_button: ViewHandle<ActionButton>,
delete_button: ViewHandle<ActionButton>,
}
impl CustomRouterView {
#[cfg(feature = "local_fs")]
pub fn new(router: CustomModelRouter, ctx: &mut ViewContext<Self>) -> Self {
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let open_file_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Open file", SecondaryTheme)
.with_icon(Icon::File)
.with_size(ButtonSize::Small)
.with_height(HEADER_BUTTON_HEIGHT)
.on_click(|ctx| {
ctx.dispatch_typed_action(CustomRouterViewAction::OpenFile);
})
});
open_file_button.update(ctx, |button, ctx| {
button.set_disabled(router.source_path.is_none(), ctx);
});
let edit_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Edit", SecondaryTheme)
.with_icon(Icon::Pencil)
.with_size(ButtonSize::Small)
.with_height(HEADER_BUTTON_HEIGHT)
.on_click(|ctx| {
ctx.dispatch_typed_action(CustomRouterViewAction::Edit);
})
});
edit_button.update(ctx, |button, ctx| {
button.set_disabled(!is_any_ai_enabled, ctx);
});
let delete_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Delete", DangerSecondaryTheme)
.with_icon(Icon::Trash)
.with_size(ButtonSize::Small)
.with_height(HEADER_BUTTON_HEIGHT)
.on_click(|ctx| {
ctx.dispatch_typed_action(CustomRouterViewAction::Delete);
})
});
delete_button.update(ctx, |button, ctx| {
button.set_disabled(!is_any_ai_enabled, ctx);
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, _, ctx| {
let enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
me.edit_button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
});
me.delete_button.update(ctx, |button, ctx| {
button.set_disabled(!enabled, ctx);
});
ctx.notify();
});
Self {
router,
open_file_button,
edit_button,
delete_button,
}
}
#[allow(dead_code)]
pub fn router(&self) -> &CustomModelRouter {
&self.router
}
#[allow(dead_code)]
pub fn update_router(&mut self, router: CustomModelRouter, ctx: &mut ViewContext<Self>) {
self.router = router;
ctx.notify();
}
}
impl Entity for CustomRouterView {
type Event = CustomRouterViewEvent;
}
impl View for CustomRouterView {
fn ui_name() -> &'static str {
"CustomRouterView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let is_any_ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
let text_color = if is_any_ai_enabled {
appearance.theme().active_ui_text_color()
} else {
appearance.theme().disabled_ui_text_color()
};
let sub_color = if is_any_ai_enabled {
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
} else {
appearance.theme().disabled_ui_text_color()
};
// Header row: name + buttons
let name_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(
self.router.info.display_name.clone(),
appearance.ui_font_family(),
14.,
)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(text_color.into())
.finish(),
)
.with_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(ChildView::new(&self.open_file_button).finish())
.with_margin_right(8.)
.finish(),
)
.with_child(
Container::new(ChildView::new(&self.edit_button).finish())
.with_margin_right(8.)
.finish(),
)
.with_child(ChildView::new(&self.delete_button).finish())
.finish(),
)
.finish();
// Type label row
let type_label = match &self.router.routing {
CustomModelRouting::Complexity(_) => "Complexity-based routing",
CustomModelRouting::Prompt(_) => "Prompt-based routing",
};
let type_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(Icon::Dataflow.to_warpui_icon(sub_color).finish())
.with_width(12.)
.with_height(12.)
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Text::new(type_label, appearance.ui_font_family(), 12.)
.with_color(sub_color.into())
.finish(),
)
.finish();
// Targets summary
let targets_row = render_targets_row(
&self.router.routing,
appearance,
sub_color,
is_any_ai_enabled,
app,
);
Container::new(
Flex::column()
.with_child(Container::new(name_row).with_margin_bottom(8.).finish())
.with_child(Container::new(type_row).with_margin_bottom(4.).finish())
.with_child(targets_row)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_border(
warpui::elements::Border::new(1.).with_border_fill(appearance.theme().outline()),
)
.with_corner_radius(warpui::elements::CornerRadius::with_all(
warpui::elements::Radius::Pixels(4.),
))
.with_horizontal_padding(16.)
.with_vertical_padding(12.)
.finish()
}
}
#[cfg(feature = "local_fs")]
impl warpui::TypedActionView for CustomRouterView {
type Action = CustomRouterViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CustomRouterViewAction::OpenFile => {
if let Some(path) = self.router.source_path.clone() {
ctx.emit(CustomRouterViewEvent::OpenFile(path));
}
}
CustomRouterViewAction::Edit => ctx.emit(CustomRouterViewEvent::Edit),
CustomRouterViewAction::Delete => ctx.emit(CustomRouterViewEvent::Delete),
}
}
}
fn render_targets_row(
routing: &CustomModelRouting,
appearance: &Appearance,
sub_color: galaxy_core::ui::theme::Fill,
_is_ai_enabled: bool,
app: &AppContext,
) -> Box<dyn Element> {
let mut flex = Flex::column();
match routing {
CustomModelRouting::Complexity(c) => {
flex.add_child(render_model_line(
"Default:",
model_display_name(&c.default, app),
appearance,
sub_color,
));
if let Some(easy) = &c.easy {
flex.add_child(
Container::new(render_model_line(
"Easy:",
model_display_name(easy, app),
appearance,
sub_color,
))
.with_margin_top(2.)
.finish(),
);
}
if let Some(medium) = &c.medium {
flex.add_child(
Container::new(render_model_line(
"Medium:",
model_display_name(medium, app),
appearance,
sub_color,
))
.with_margin_top(2.)
.finish(),
);
}
if let Some(hard) = &c.hard {
flex.add_child(
Container::new(render_model_line(
"Hard:",
model_display_name(hard, app),
appearance,
sub_color,
))
.with_margin_top(2.)
.finish(),
);
}
}
CustomModelRouting::Prompt(p) => {
flex.add_child(render_model_line(
"Default:",
model_display_name(&p.default_model, app),
appearance,
sub_color,
));
let rule_count = p.rules.len();
if rule_count > 0 {
let label = if rule_count == 1 {
"1 rule".to_string()
} else {
format!("{rule_count} rules")
};
flex.add_child(
Container::new(
Text::new(label, appearance.ui_font_family(), 12.)
.with_color(sub_color.into())
.finish(),
)
.with_margin_top(2.)
.finish(),
);
}
}
}
flex.finish()
}
/// Resolves a concrete model id (e.g. `claude-4-5-haiku`) to its display
/// name/alias (e.g. `claude 4.5 haiku`), falling back to the raw id when the
/// model isn't known to the client.
fn model_display_name(model_id: &str, app: &AppContext) -> String {
LLMPreferences::as_ref(app)
.get_llm_info(&LLMId::from(model_id))
.map(|info| info.display_name.clone())
.unwrap_or_else(|| model_id.to_string())
}
fn render_model_line(
label: impl Into<String>,
model_id: impl Into<String>,
appearance: &Appearance,
sub_color: galaxy_core::ui::theme::Fill,
) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
Text::new(label.into(), appearance.ui_font_family(), 12.)
.with_color(sub_color.into())
.finish(),
)
.with_margin_right(6.)
.finish(),
)
.with_child(
Text::new(model_id.into(), appearance.ui_font_family(), 12.)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.finish()
}
/// A card rendering a file that failed to parse as a custom model router.
#[cfg(feature = "local_fs")]
pub fn render_router_error_card(
file_name: impl Into<String>,
error_message: impl Into<String>,
appearance: &Appearance,
) -> Box<dyn Element> {
use warpui::elements::Shrinkable;
let theme = appearance.theme();
let error_fill = galaxy_core::ui::theme::Fill::Solid(theme.ui_error_color());
let sub = theme.sub_text_color(theme.surface_2());
let file_name = file_name.into();
let error_message = error_message.into();
let name_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(Icon::AlertTriangle.to_warpui_icon(error_fill).finish())
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(8.)
.finish(),
)
.with_child(
Text::new(file_name, appearance.ui_font_family(), 13.)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.finish();
// Truncate long error messages to keep the card readable.
let truncated = if error_message.chars().count() > 200 {
format!("{}", error_message.chars().take(200).collect::<String>())
} else {
error_message.to_string()
};
let error_row = Shrinkable::new(
1.,
Text::new(truncated, appearance.ui_font_family(), 11.)
.with_color(sub.into())
.finish(),
)
.finish();
Container::new(
Flex::column()
.with_child(Container::new(name_row).with_margin_bottom(6.).finish())
.with_child(error_row)
.finish(),
)
.with_background(theme.surface_2())
.with_border(warpui::elements::Border::new(1.).with_border_fill(error_fill))
.with_corner_radius(warpui::elements::CornerRadius::with_all(
warpui::elements::Radius::Pixels(4.),
))
.with_horizontal_padding(16.)
.with_vertical_padding(10.)
.finish()
}
@@ -1,15 +1,13 @@
use galaxyui::elements::{ChildView, Container, Dismiss, Empty};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
elements::{ChildView, Container, Dismiss, Empty},
ui_components::components::UiComponent,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
server::ids::SyncId,
ui_components::dialog::{dialog_styles, Dialog},
view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme},
};
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.;
@@ -4,26 +4,25 @@ use std::path::{Path, PathBuf};
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
use settings::Setting;
use galaxy_util::path::user_friendly_path;
use galaxyui::elements::{
Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentElement, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::{
elements::{
Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MainAxisSize, MouseStateHandle, ParentElement, Text,
},
platform::Cursor,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use settings::Setting;
use crate::{
ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent},
appearance::Appearance,
ui_components::icons,
view_components::action_button::{ActionButton, SecondaryTheme},
view_components::{DropdownItem, FilterableDropdown},
workspace::tab_settings::{
DirectoryTabColor, DirectoryTabColors, TabSettings, TabSettingsChangedEvent,
},
use crate::ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent};
use crate::appearance::Appearance;
use crate::ui_components::icons;
use crate::view_components::action_button::{ActionButton, SecondaryTheme};
use crate::view_components::{DropdownItem, FilterableDropdown};
use crate::workspace::tab_settings::{
canonical_directory_key, DirectoryTabColor, DirectoryTabColors, TabSettings,
TabSettingsChangedEvent,
};
const ADD_DIRECTORY_LABEL: &str = "+ Add directory…";
@@ -86,8 +85,8 @@ impl DirectoryColorAddPicker {
// cache in `refresh_items`, so the noisier events (`Modified`/`Queried`) are
// cheap when nothing relevant has changed.
match event {
CodebaseIndexManagerEvent::NewIndexCreated
| CodebaseIndexManagerEvent::SyncStateUpdated
CodebaseIndexManagerEvent::NewIndexCreated { .. }
| CodebaseIndexManagerEvent::SyncStateUpdated { .. }
| CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { .. }
| CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => {
me.refresh_items(ctx);
@@ -285,15 +284,6 @@ impl TypedActionView for DirectoryColorAddPicker {
}
}
/// Canonicalizes `path` using the same fallback logic that [`DirectoryTabColors::with_color`]
/// uses, so candidate keys line up with the keys stored in the setting.
fn canonical_key(path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
/// Computes the set of directory paths that should be offered in the add-directory dropdown.
///
/// Candidates are the union of indexed codebase paths and persisted workspace
@@ -321,7 +311,7 @@ fn compute_candidate_paths(
continue;
}
let key = canonical_key(&path);
let key = canonical_directory_key(&path);
if let Some(existing_color) = existing.0.get(&key) {
if !matches!(existing_color, DirectoryTabColor::Suppressed) {
+66 -72
View File
@@ -1,79 +1,77 @@
use super::{
agent_assisted_environment_modal::{
AgentAssistedEnvironmentModal, AgentAssistedEnvironmentModalEvent,
},
delete_environment_confirmation_dialog::{
DeleteEnvironmentConfirmationDialog, DeleteEnvironmentConfirmationDialogEvent,
},
editor_text_colors,
settings_page::{
MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, CONTENT_FONT_SIZE,
},
update_environment_form::{
EnvironmentFormInitArgs, EnvironmentFormValues, GithubAuthRedirectTarget,
UpdateEnvironmentForm, UpdateEnvironmentFormEvent,
},
SettingsSection,
};
use crate::{
ai::cloud_environments::{self, CloudAmbientAgentEnvironment},
appearance::Appearance,
cloud_object::{
model::persistence::{CloudModel, CloudModelEvent},
CloudObjectLocation, GenericStringObjectFormat, JsonObjectType, Owner, Space,
},
drive::CloudObjectTypeAndId,
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
root_view::CreateEnvironmentArg,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::{ClientId, ServerId, SyncId},
},
terminal::view::init_environment::mode_selector::{
EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent,
},
themes::theme::Fill as ThemeFill,
ui_components::{blended_colors, buttons::icon_button_with_color, icons::Icon},
util::time_format::format_approx_duration_from_now_utc,
view_components::{
render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig, DismissibleToast,
COPY_FEEDBACK_DURATION,
},
workspace::{ToastStack, WorkspaceAction},
workspaces::user_workspaces::UserWorkspaces,
};
use std::collections::HashMap;
use instant::Instant;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::editor::NavigationKey;
use galaxy_graphql::scalars::time::ServerTimestamp;
use galaxyui::elements::{
Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::prelude::ChildView;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::windowing::state::ApplicationStage;
use galaxyui::windowing::{self, WindowManager};
use galaxyui::{
elements::{
Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch,
Stack, Text,
},
fonts::{Properties, Weight},
prelude::ChildView,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
windowing::{self, state::ApplicationStage, WindowManager},
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use instant::Instant;
use pathfinder_geometry::vector::vec2f;
use std::collections::HashMap;
use super::agent_assisted_environment_modal::{
AgentAssistedEnvironmentModal, AgentAssistedEnvironmentModalEvent,
};
use super::delete_environment_confirmation_dialog::{
DeleteEnvironmentConfirmationDialog, DeleteEnvironmentConfirmationDialogEvent,
};
use super::settings_page::{
MatchData, PageType, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, CONTENT_FONT_SIZE,
};
use super::update_environment_form::{
EnvironmentFormInitArgs, EnvironmentFormValues, UpdateEnvironmentForm,
UpdateEnvironmentFormEvent,
};
use super::{editor_text_colors, SettingsSection};
use crate::ai::ambient_agents::github_auth_url::GithubAuthRedirectTarget;
use crate::ai::cloud_environments::{self, CloudAmbientAgentEnvironment};
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{
CloudObjectLocation, CloudObjectLookup as _, GenericStringObjectFormat, JsonObjectType, Owner,
Space,
};
use crate::drive::CloudObjectTypeAndId;
use crate::editor::{
EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::root_view::CreateEnvironmentArg;
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::{ClientId, ServerId, SyncId};
use crate::terminal::view::init_environment::mode_selector::{
EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent,
};
use crate::themes::theme::Fill as ThemeFill;
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button_with_color;
use crate::ui_components::icons::Icon;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::view_components::{
render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig, DismissibleToast,
COPY_FEEDBACK_DURATION,
};
use crate::workspace::{ToastStack, WorkspaceAction};
use crate::workspaces::user_workspaces::UserWorkspaces;
mod new_environment_button;
use new_environment_button::NewEnvironmentButtonView;
#[cfg(not(target_family = "wasm"))]
#[allow(unused_imports)] // IntegrationsClient trait is used in fetch_github_repos
use {
@@ -2041,13 +2039,9 @@ impl SettingsPageMeta for EnvironmentsPageView {
}
}
use crate::pane_group::{
focus_state::PaneFocusHandle,
pane::{
view::{HeaderContent, HeaderRenderContext},
BackingView,
},
};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view::{HeaderContent, HeaderRenderContext};
use crate::pane_group::pane::BackingView;
impl BackingView for EnvironmentsPageView {
type PaneHeaderOverflowMenuAction = EnvironmentsPageAction;
@@ -1,18 +1,17 @@
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::{
elements::{
Border, Container, CornerRadius, DispatchEventResult, EventHandler, Flex,
MainAxisAlignment, MouseStateHandle, ParentElement as _, Radius, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
AppContext, BlurContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use crate::editor::EditorView;
use super::EnvironmentsPageAction;
use crate::editor::EditorView;
pub struct NewEnvironmentButtonView {
trigger_mouse_state: MouseStateHandle,
@@ -1,3 +1,13 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use instant::Instant;
use galaxy_core::ui::appearance::Appearance;
use warpui::elements::Empty;
use warpui::platform::WindowStyle;
use warpui::{App, AppContext, Element, Entity, TypedActionView, View, WindowId};
use super::*;
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::ai::cloud_environments::{
@@ -6,23 +16,16 @@ use crate::ai::cloud_environments::{
use crate::auth::AuthStateProvider;
use crate::network::NetworkStatus;
use crate::root_view::CreateEnvironmentArg;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ClientId, ServerId, SyncId};
use crate::server::server_api::ServerApiProvider;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::server::sync_queue::SyncQueue;
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::view::init_environment::mode_selector::EnvironmentSetupModeSelector;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::Empty;
use galaxyui::platform::WindowStyle;
use galaxyui::{App, AppContext, Element, Entity, TypedActionView, View, WindowId};
use instant::Instant;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
fn make_test_environment(
name: &str,
@@ -1308,7 +1311,7 @@ fn test_toolbar_renders_search_editor_view() {
let env_page_id = env_page_handle.id();
let search_editor_id = env_page.search_editor.id();
let chain = presenter.borrow().ancestors(search_editor_id);
let chain = ctx.view_ancestors(window_id, search_editor_id);
assert!(
chain.len() >= 2,
"Expected search editor to be laid out as a child view; got ancestors={chain:?}"
@@ -1399,8 +1402,6 @@ fn test_render_environment_card_with_last_used_never() {
#[test]
fn test_render_environment_card_with_last_used_timestamp() {
use chrono::{Duration, Utc};
use galaxy_graphql::scalars::time::ServerTimestamp;
App::test((), |mut app| async move {
app.add_singleton_model(|_| Appearance::mock());
+38 -14
View File
@@ -1,9 +1,22 @@
use std::path::PathBuf;
use uuid::Uuid;
use galaxy_core::features::FeatureFlag;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize,
ParentElement, Shrinkable, Text, Wrap,
};
use warpui::fonts::{Properties, Weight};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
};
use crate::ai::execution_profiles::{
ActionPermission, AskUserQuestionPermission, WriteToPtyPermission,
ActionPermission, AskUserQuestionPermission, RunAgentsPermission, WriteToPtyPermission,
};
use crate::ai::llms::LLMPreferences;
use crate::appearance::Appearance;
@@ -12,19 +25,6 @@ use crate::settings::AISettings;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::TemplatableMCPServerManager;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::ParentElement;
use galaxyui::SingletonEntity;
use galaxyui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize,
Shrinkable, Text, Wrap,
},
fonts::{Properties, Weight},
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle,
};
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum ExecutionProfileViewAction {
@@ -304,6 +304,15 @@ impl View for ExecutionProfileView {
is_any_ai_enabled,
),
));
permissions_column.add_child(with_standard_vertical_margin(
render_run_agents_permission_line_with_icon(
Icon::Workflow,
"Run agents:",
&profile.run_agents,
appearance,
is_any_ai_enabled,
),
));
permissions_column.add_child(with_standard_vertical_margin(
render_action_permission_line_with_icon(
@@ -745,6 +754,21 @@ fn render_ask_user_question_permission_line_with_icon(
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_run_agents_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
permission: &RunAgentsPermission,
appearance: &Appearance,
is_ai_enabled: bool,
) -> Box<dyn Element> {
let permission_text = match permission {
RunAgentsPermission::NeverAllow | RunAgentsPermission::Unknown => "Never",
RunAgentsPermission::AlwaysAllow => "Always allow",
RunAgentsPermission::AlwaysAsk => "Always ask",
};
render_permission_line_with_icon(icon, label, permission_text, appearance, is_ai_enabled)
}
fn render_bool_permission_line_with_icon(
icon: Icon,
label: impl Into<String>,
@@ -1,34 +1,30 @@
use std::{cell::RefCell, collections::HashMap};
use std::cell::RefCell;
use std::collections::HashMap;
use galaxy_core::features::FeatureFlag;
use galaxyui::{
elements::{Flex, MouseStateHandle, ParentElement},
ui_components::{components::UiComponent, switch::SwitchStateHandle},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use settings::{Setting, ToggleableSetting};
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{Flex, MouseStateHandle, ParentElement};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::{
appearance::Appearance,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::settings_page::{
render_body_item, render_dropdown_item, AdditionalInfo, LocalOnlyIconState, ToggleState,
},
util::file::external_editor::{
settings::{
EditorChoice, EditorLayout, OpenCodePanelsFileEditor, OpenFileEditor, OpenFileLayout,
PreferMarkdownViewer, PreferTabbedEditorView,
},
EditorSettings, SUPPORTED_EDITORS,
},
view_components::{Dropdown, DropdownItem},
use crate::appearance::Appearance;
use crate::server::telemetry::TelemetryEvent;
use crate::settings_view::settings_page::{
render_body_item, render_dropdown_item, AdditionalInfo, LocalOnlyIconState, ToggleState,
};
use crate::util::file::external_editor::settings::{
EditorChoice, EditorLayout, OpenCodePanelsFileEditor, OpenFileEditor, OpenFileLayout,
PreferMarkdownViewer, PreferTabbedEditorView,
};
use crate::util::file::external_editor::{EditorSettings, SUPPORTED_EDITORS};
use crate::view_components::{Dropdown, DropdownItem};
use crate::{report_if_error, send_telemetry_from_ctx};
const TABBED_FILE_VIEWER_TOGGLE_HEADER: &str = "Group files into single editor pane";
const TABBED_FILE_VIEWER_TOGGLE_DESCRIPTION: &str = "When this setting is on, any files opened in the same tab will be automatically grouped into a single editor pane.";
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum ExternalEditorAction {
SetEditor(EditorChoice),
SetCodePanelsEditor(EditorChoice),
+14 -19
View File
@@ -1,22 +1,17 @@
use galaxyui::{
elements::{CrossAxisAlignment, Fill, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use galaxyui::elements::{CrossAxisAlignment, Fill, Flex, ParentElement, Shrinkable};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::{
appearance::Appearance,
editor::{EditorView, Event, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
terminal::{
available_shells::{AvailableShell, AvailableShells},
local_tty::shell::is_valid_path_or_command_for_supported_shell,
session_settings::{SessionSettings, SessionSettingsChangedEvent},
},
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
use crate::appearance::Appearance;
use crate::editor::{EditorView, Event, SingleLineEditorOptions, TextOptions};
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::available_shells::{AvailableShell, AvailableShells};
use crate::terminal::local_tty::shell::is_valid_path_or_command_for_supported_shell;
use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent};
use crate::view_components::dropdown::TOP_MENU_BAR_HEIGHT;
use crate::view_components::{Dropdown, DropdownItem};
use crate::{report_if_error, send_telemetry_from_ctx};
/// A view for configuring the initial shell for new sessions. This can be the
/// user's login shell, the default installed version of zsh, bash, or fish,
@@ -35,7 +30,7 @@ pub struct StartupShellView {
is_custom_path_valid: bool,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum NewSessionShellAction {
/// Changes the user's startup shell to the given option. This also hides
/// the custom shell path editor if a non-custom shell was chosen.
+16 -20
View File
@@ -1,28 +1,24 @@
use std::{cell::RefCell, collections::HashMap, time::Duration};
use std::cell::RefCell;
use std::collections::HashMap;
use std::time::Duration;
use settings::{Setting, ToggleableSetting};
use galaxyui::elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement, Text,
};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement,
Text,
},
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use settings::{Setting, ToggleableSetting};
use crate::{
appearance::Appearance,
editor::{self, EditorView, SingleLineEditorOptions, TextOptions},
report_if_error,
settings_view::{
features_page::render_group,
settings_page::{render_body_item, LocalOnlyIconState, ToggleState},
},
undo_close::{settings::UndoCloseEnabled, UndoCloseSettings},
};
use crate::appearance::Appearance;
use crate::editor::{self, EditorView, SingleLineEditorOptions, TextOptions};
use crate::report_if_error;
use crate::settings_view::features_page::render_group;
use crate::settings_view::settings_page::{render_body_item, LocalOnlyIconState, ToggleState};
use crate::undo_close::settings::UndoCloseEnabled;
use crate::undo_close::UndoCloseSettings;
#[derive(Debug, Clone, Copy)]
pub enum Action {
@@ -1,22 +1,19 @@
use galaxyui::{
elements::{Container, CrossAxisAlignment, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use itertools::Itertools;
use galaxyui::elements::{Container, CrossAxisAlignment, Flex, ParentElement, Shrinkable};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::{
appearance::Appearance,
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::features_page::render_group,
terminal::session_settings::*,
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
use crate::appearance::Appearance;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use crate::server::telemetry::TelemetryEvent;
use crate::settings_view::features_page::render_group;
use crate::terminal::session_settings::*;
use crate::view_components::dropdown::TOP_MENU_BAR_HEIGHT;
use crate::view_components::{Dropdown, DropdownItem};
use crate::{report_if_error, send_telemetry_from_ctx};
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
#[allow(clippy::enum_variant_names)]
pub enum WorkingDirectoryAction {
/// Sets the mode that should be used for all new sessions, independent of
@@ -387,12 +384,11 @@ fn create_editor(
editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&initial_value, ctx);
});
let editor_handle = editor.clone();
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| match event {
ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| match event {
// If the user presses enter or focus moves out of the editor view,
// update our configuration to match the current value.
EditorEvent::Blurred | EditorEvent::Enter => {
let editor_contents = editor_handle.as_ref(ctx).buffer_text(ctx);
let editor_contents = editor.as_ref(ctx).buffer_text(ctx);
me.handle_action(
&WorkingDirectoryAction::SetCustomWorkingDirectoryValue(source, editor_contents),
ctx,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
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);
}
}
}
+29 -42
View File
@@ -1,53 +1,40 @@
use std::collections::HashMap;
use super::{
settings_page::{
render_sub_header, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
},
SettingsSection,
};
use crate::send_telemetry_from_ctx;
use crate::{appearance::Appearance, themes};
use crate::{
editor::EditorView, keyboard::write_custom_keybinding, util::bindings::CommandBinding,
};
use crate::{
editor::{
Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
},
keyboard::UserDefinedKeybinding,
};
use crate::{search_bar::SearchBar, settings::CloudPreferencesSettings};
use crate::{
util::bindings::{
filter_bindings_including_keystroke, reset_keybinding_to_default, set_custom_keybinding,
},
TelemetryEvent,
};
use itertools::Itertools;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::{elements::Wrap, units::Pixels};
use galaxyui::elements::{
Align, Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Fill, Flex,
Hoverable, MouseState, MouseStateHandle, ParentElement, Radius, SavePosition, ScrollbarWidth,
Shrinkable, Text, Wrap,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::{DescriptionContext, Keystroke, Trigger};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::units::Pixels;
use galaxyui::{
elements::{
Align, Border, ClippedScrollStateHandle, ClippedScrollable, Container, CornerRadius, Empty,
EventHandler, Fill, Flex, Hoverable, MouseState, MouseStateHandle, ParentElement, Radius,
SavePosition, ScrollbarWidth, Shrinkable,
},
fonts::Weight,
keymap::{Keystroke, Trigger},
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use galaxyui::{
elements::{ConstrainedBox, DispatchEventResult},
presenter::ChildView,
use super::settings_page::{
render_sub_header, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
};
use galaxyui::{
elements::{CrossAxisAlignment, Text},
keymap::DescriptionContext,
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::keyboard::{write_custom_keybinding, UserDefinedKeybinding};
use crate::search_bar::SearchBar;
use crate::settings::CloudPreferencesSettings;
use crate::util::bindings::{
filter_bindings_including_keystroke, reset_keybinding_to_default, set_custom_keybinding,
CommandBinding,
};
use crate::{send_telemetry_from_ctx, themes, TelemetryEvent};
const FONT_DELTA: f32 = 2.;
const CANCEL_SAVE_BUTTONS_SPACING: f32 = 4.0;
@@ -191,7 +178,7 @@ struct RowMouseStates {
}
/// Wrapper around the CommandBinding structure that includes the styling/render-specific
/// attribtues (such as MouseStateHandles)
/// attributes (such as MouseStateHandles)
#[derive(Clone)]
pub struct KeybindingRow {
pub binding: CommandBinding,
+176 -53
View File
@@ -1,59 +1,51 @@
use super::{
flags,
settings_page::{
render_body_item, render_customer_type_badge, AdditionalInfo, LocalOnlyIconState,
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState,
HEADER_PADDING,
},
SettingsAction, SettingsSection, ToggleSettingActionPair,
};
use crate::auth::{AuthStateProvider, UserUid};
use crate::autoupdate::{self, AutoupdateStage, AutoupdateState};
use crate::send_telemetry_from_ctx;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
appearance::Appearance,
auth::{auth_state::AuthState, auth_view_modal::AuthViewVariant},
report_if_error,
settings::cloud_preferences::CloudPreferencesSettings,
TelemetryEvent,
};
use crate::{auth::auth_manager::AuthManager, server::ids::ServerId};
use crate::{auth::auth_manager::LoginGatedFeature, workspaces::workspace::CustomerType};
use crate::{workspace::WorkspaceAction, workspaces::update_manager::TeamUpdateManager};
use std::sync::{Arc, Mutex};
use ::settings::{Setting, ToggleableSetting};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
use galaxy_core::{channel::ChannelState, context_flag::ContextFlag};
use galaxyui::{
assets::asset_cache::AssetSource,
elements::{Border, Empty, MainAxisAlignment, MainAxisSize},
id,
platform::Cursor,
ui_components::switch::SwitchStateHandle,
};
use galaxyui::{
elements::{
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex,
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
Action, AppContext,
};
use galaxyui::{
elements::{CacheOption, Image},
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
};
use galaxyui::{fonts::Weight, keymap::ContextPredicate};
use galaxyui::{
Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use std::sync::{Arc, Mutex};
use galaxy_core::channel::ChannelState;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
#[cfg(not(target_family = "wasm"))]
use galaxy_server_client::iap::{IapCredentialsState, IapManager, IapManagerEvent};
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::elements::{
Align, Border, CacheOption, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Element, Empty, Flex, Image, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
Radius, Shrinkable, Text,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::ContextPredicate;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
id, Action, AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use super::settings_page::{
render_body_item, render_customer_type_badge, AdditionalInfo, LocalOnlyIconState, MatchData,
PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState,
HEADER_PADDING,
};
use super::{flags, SettingsAction, SettingsSection, ToggleSettingActionPair};
use crate::appearance::Appearance;
use crate::auth::auth_manager::{AuthManager, LoginGatedFeature};
use crate::auth::auth_state::AuthState;
use crate::auth::auth_view_modal::AuthViewVariant;
use crate::auth::{AuthStateProvider, UserUid};
use crate::autoupdate::{self, AutoupdateStage, AutoupdateState};
use crate::server::ids::ServerId;
use crate::settings::cloud_preferences::CloudPreferencesSettings;
use crate::workspace::WorkspaceAction;
use crate::workspaces::update_manager::TeamUpdateManager;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::CustomerType;
use crate::{report_if_error, send_telemetry_from_ctx, TelemetryEvent};
const PHOTO_SIZE: f32 = 40.;
const REFERRAL_CTA: &str = "Earn rewards by sharing Warp with friends & colleagues";
@@ -132,6 +124,8 @@ pub enum MainPageAction {
},
SignupAnonymousUser,
OpenUrl(String),
#[cfg(not(target_family = "wasm"))]
RefreshIapCredentials,
}
impl MainPageAction {
@@ -146,7 +140,6 @@ impl MainPageAction {
impl From<&MainPageAction> for LoginGatedFeature {
fn from(val: &MainPageAction) -> LoginGatedFeature {
use MainPageAction::*;
match val {
Upgrade { .. } => "Upgrade Plan",
GenerateStripeBillingPortalLink { .. } => "Generate Stripe Billing Portal Link",
@@ -239,6 +232,11 @@ impl TypedActionView for MainSettingsPageView {
MainPageAction::OpenUrl(url) => {
ctx.open_url(url);
}
#[cfg(not(target_family = "wasm"))]
MainPageAction::RefreshIapCredentials => {
IapManager::handle(ctx).update(ctx, |manager, ctx| manager.start_refresh(ctx));
ctx.notify();
}
}
}
}
@@ -281,6 +279,17 @@ impl MainSettingsPageView {
widgets.push(Box::new(EarnRewardsWidget::default()));
#[cfg(not(target_family = "wasm"))]
if IapManager::as_ref(ctx).is_enabled() {
widgets.push(Box::new(IapCredentialsWidget::default()));
let iap_manager_handle = IapManager::handle(ctx);
ctx.subscribe_to_model(&iap_manager_handle, |_, _, e, ctx| {
if matches!(e, IapManagerEvent::StateChanged) {
ctx.notify();
}
})
}
if ChannelState::app_version().is_some() {
widgets.push(Box::new(VersionInfoWidget::default()));
}
@@ -1062,6 +1071,120 @@ impl LogoutWidget {
}
}
/// Widget displaying IAP credential state and a refresh button. Only
/// visible on staging channels where IAP is active.
#[cfg(not(target_family = "wasm"))]
#[derive(Default)]
struct IapCredentialsWidget {
refresh_button_mouse_state: MouseStateHandle,
}
#[cfg(not(target_family = "wasm"))]
impl SettingsWidget for IapCredentialsWidget {
type View = MainSettingsPageView;
fn search_terms(&self) -> &str {
"iap staging gcloud proxy credentials"
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
// `is_enabled()` gates widget registration in `MainSettingsPageView::new`,
// so `state()` should be `Some` here; bail out defensively though.
let Some(state) = IapManager::as_ref(app).state() else {
return Empty::new().finish();
};
let ansi_red: ColorU = appearance.theme().terminal_colors().bright.red.into();
let disabled: ColorU = appearance.theme().disabled_ui_text_color().into();
let active: ColorU = appearance.theme().active_ui_text_color().into();
let (status_text, status_color): (String, ColorU) = match &state {
IapCredentialsState::Missing => ("Not yet loaded".to_string(), disabled),
IapCredentialsState::Refreshing { .. } => ("Refreshing…".to_string(), active),
IapCredentialsState::Loaded(cached) => {
let remaining = cached
.expires_at
.saturating_duration_since(instant::Instant::now());
let mins = remaining.as_secs() / 60;
(format!("Loaded (refreshes in ~{mins}m)"), active)
}
IapCredentialsState::Failed { message, .. } => (format!("Failed: {message}"), ansi_red),
IapCredentialsState::EnvInjected { .. } => {
("Using injected token (WARP_IAP_TOKEN)".to_string(), active)
}
};
let is_refreshing = matches!(state, IapCredentialsState::Refreshing { .. });
let label = Align::new(
Text::new_inline(
"Staging IAP credentials".to_string(),
appearance.ui_font_family(),
REGULAR_TEXT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.left()
.finish();
let status = Container::new(
appearance
.ui_builder()
.paragraph(status_text)
.with_style(UiComponentStyles {
font_color: Some(status_color),
font_size: Some(REGULAR_TEXT_FONT_SIZE),
..Default::default()
})
.build()
.finish(),
)
.with_margin_top(4.)
.finish();
let refresh_button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.refresh_button_mouse_state.clone(),
)
.with_text_label(if is_refreshing {
"Refreshing…".into()
} else {
"Refresh".into()
})
.with_style(UiComponentStyles {
font_size: Some(12.),
padding: Some(Coords::uniform(6.).left(16.).right(16.)),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(MainPageAction::RefreshIapCredentials);
})
.finish();
let button_row = Container::new(Align::new(refresh_button).left().finish())
.with_margin_top(8.)
.finish();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(label)
.with_child(status)
.with_child(button_row)
.finish(),
)
.with_margin_top(VERTICAL_MARGIN)
.finish()
}
}
impl SettingsWidget for LogoutWidget {
type View = MainSettingsPageView;
@@ -1,14 +1,12 @@
use galaxyui::elements::{ChildView, Container, Dismiss, Empty};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
elements::{ChildView, Container, Dismiss, Empty},
ui_components::components::UiComponent,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
ui_components::dialog::{dialog_styles, Dialog},
view_components::action_button::{ActionButton, DangerPrimaryTheme, NakedTheme},
};
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 DestructiveMCPConfirmationDialogEvent {
+96 -63
View File
@@ -1,64 +1,63 @@
#[cfg(not(target_family = "wasm"))]
use std::collections::HashMap;
use std::path::Path;
#[cfg(feature = "local_fs")]
use std::sync::Arc;
use std::{collections::HashMap, path::Path};
#[cfg(feature = "local_fs")]
#[cfg(not(target_family = "wasm"))]
use diesel::SqliteConnection;
use galaxy_core::{
send_telemetry_from_ctx,
ui::{appearance::Appearance, theme::color::internal_colors},
};
use galaxy_editor::{
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
#[cfg(feature = "local_fs")]
use parking_lot::Mutex;
use pathfinder_geometry::vector::vec2f;
use settings::Setting as _;
use uuid::Uuid;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::content::buffer::InitialBufferState;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, Text,
},
platform::Cursor,
ui_components::components::UiComponent,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
#[cfg(not(target_family = "wasm"))]
use parking_lot::Mutex;
use pathfinder_geometry::vector::vec2f;
use uuid::Uuid;
use crate::{
ai::{
blocklist::secret_redaction::find_secrets_in_text,
mcp::{
parsing::{prettify_json, resolve_json, ParsedTemplatableMCPServerResult},
templatable::CloudTemplatableMCPServer,
MCPServer, TemplatableMCPServer, TemplatableMCPServerInstallation,
TemplatableMCPServerManager, TransportType,
},
},
banner::{Banner, BannerTextContent},
cloud_object::{CloudObject, Space},
code::editor::view::{CodeEditorRenderOptions, CodeEditorView},
persistence::ModelEvent,
server::{
cloud_objects::update_manager::InitiatedBy,
telemetry::{MCPTemplateCreationSource, TelemetryEvent},
},
settings_view::mcp_servers::{
destructive_mcp_confirmation_dialog::{
DestructiveMCPConfirmationDialog, DestructiveMCPConfirmationDialogEvent,
DestructiveMCPConfirmationDialogVariant,
},
style, ServerCardItemId,
},
ui_components::{buttons::icon_button, icons::Icon},
view_components::{
action_button::{ActionButton, DangerNakedTheme, DangerSecondaryTheme, PrimaryTheme},
DismissibleToast,
},
workspace::ToastStack,
GlobalResourceHandlesProvider,
use crate::ai::blocklist::secret_redaction::find_secrets_in_text;
use crate::ai::mcp::parsing::{prettify_json, resolve_json, ParsedTemplatableMCPServerResult};
use crate::ai::mcp::templatable::CloudTemplatableMCPServer;
use crate::ai::mcp::{
MCPServer, TemplatableMCPServer, TemplatableMCPServerInstallation, TemplatableMCPServerManager,
TransportType,
};
use crate::banner::{Banner, BannerTextContent};
use crate::cloud_object::{CloudObject, Space};
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
use crate::persistence::ModelEvent;
#[cfg(feature = "local_fs")]
use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope};
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::server::telemetry::{MCPTemplateCreationSource, TelemetryEvent};
use crate::settings_view::mcp_servers::destructive_mcp_confirmation_dialog::{
DestructiveMCPConfirmationDialog, DestructiveMCPConfirmationDialogEvent,
DestructiveMCPConfirmationDialogVariant,
};
use crate::settings_view::mcp_servers::{style, ServerCardItemId};
use crate::terminal::safe_mode_settings::SafeModeSettings;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, DangerNakedTheme, DangerSecondaryTheme, PrimaryTheme,
};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::GlobalResourceHandlesProvider;
const DEFAULT_JSON_TEXT: &str = r#"{
"": {
@@ -124,7 +123,7 @@ pub struct MCPServersEditPageView {
log_out_icon_button_mouse_handle: MouseStateHandle,
editing_disabled_banner: ViewHandle<Banner<()>>,
#[cfg(not(target_family = "wasm"))]
#[cfg(feature = "local_fs")]
#[allow(dead_code)]
database_connection: Option<Arc<Mutex<SqliteConnection>>>,
}
@@ -175,7 +174,7 @@ impl MCPServersEditPageView {
true,
),
);
editor.set_language_with_path(Path::new("mcp.json"), ctx);
editor.set_language_with_local_path(Path::new("/mcp.json"), ctx);
editor
});
@@ -192,15 +191,14 @@ impl MCPServersEditPageView {
.with_icon(Icon::Warning)
});
#[cfg(not(target_family = "wasm"))]
let database_connection =
crate::persistence::database_file_path()
.to_str()
.and_then(|db_url| {
crate::persistence::establish_ro_connection(db_url)
.ok()
.map(|conn| Arc::new(Mutex::new(conn)))
});
#[cfg(feature = "local_fs")]
let database_connection = database_file_path_for_scope(&PersistenceScope::App)
.to_str()
.and_then(|db_url| {
establish_ro_connection(db_url)
.ok()
.map(|conn| Arc::new(Mutex::new(conn)))
});
Self {
server_card_item_id: None,
@@ -215,7 +213,7 @@ impl MCPServersEditPageView {
log_out_icon_button_mouse_handle: Default::default(),
editing_disabled_banner,
#[cfg(not(target_family = "wasm"))]
#[cfg(feature = "local_fs")]
database_connection,
}
}
@@ -531,10 +529,13 @@ impl MCPServersEditPageView {
ctx: &mut ViewContext<Self>,
templatable_mcp_server: &TemplatableMCPServer,
) -> Result<(), String> {
let safe_mode_enabled = *SafeModeSettings::as_ref(ctx).safe_mode_enabled.value();
let enterprise_enforced =
UserWorkspaces::as_ref(ctx).is_enterprise_secret_redaction_enabled();
let contains_secrets =
!find_secrets_in_text(&templatable_mcp_server.template.json).is_empty();
if contains_secrets {
if should_block_save_for_secrets(safe_mode_enabled, enterprise_enforced, contains_secrets) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
@@ -903,6 +904,19 @@ impl TypedActionView for MCPServersEditPageView {
return;
}
if parsed_servers
.iter()
.try_for_each(|parsed_server| {
self.detect_secrets_in_templatable_mcp_server(
ctx,
&parsed_server.templatable_mcp_server,
)
})
.is_err()
{
return;
}
for parsed_server in parsed_servers {
TemplatableMCPServerManager::handle(ctx).update(
ctx,
@@ -949,3 +963,22 @@ impl TypedActionView for MCPServersEditPageView {
}
}
}
/// Decide whether to block saving an MCP server config because secret
/// redaction is in force AND the parsed config contains secret-shaped strings.
///
/// We block only when redaction is actually active — either the user-level
/// Settings > Privacy > Secret redaction toggle is on, or the user's workspace
/// has enterprise enforcement enabled. With both off, the user has explicitly
/// opted to embed secrets in the config and we save it as written (#8761).
fn should_block_save_for_secrets(
safe_mode_enabled: bool,
enterprise_enforced: bool,
contains_secrets: bool,
) -> bool {
(safe_mode_enabled || enterprise_enforced) && contains_secrets
}
#[cfg(test)]
#[path = "edit_page_tests.rs"]
mod tests;
@@ -0,0 +1,48 @@
use super::should_block_save_for_secrets;
/// #8761: with redaction disabled and no enterprise enforcement, saving a
/// config that contains secrets must NOT be blocked.
#[test]
fn does_not_block_when_redaction_off_even_if_secrets_present() {
assert!(!should_block_save_for_secrets(false, false, true));
}
/// User-level toggle on AND secrets present → block. This is the case the
/// original check was written to catch; the redaction-aware predicate
/// must preserve it.
#[test]
fn blocks_when_user_redaction_on_and_secrets_present() {
assert!(should_block_save_for_secrets(true, false, true));
}
/// Enterprise enforcement alone is enough to gate the save, even if the
/// user toggled their personal redaction off — orgs that mandate redaction
/// must not be bypassed at the MCP-config layer.
#[test]
fn blocks_when_enterprise_enforced_and_secrets_present() {
assert!(should_block_save_for_secrets(false, true, true));
}
/// Configs without any detected secrets are never blocked, regardless of
/// the redaction-toggle state. The check is purely a guard against
/// accidentally persisting secrets — it has nothing to add when none exist.
#[test]
fn does_not_block_when_no_secrets_regardless_of_toggle() {
for safe_mode in [false, true] {
for enterprise in [false, true] {
assert!(
!should_block_save_for_secrets(safe_mode, enterprise, false),
"expected no block when contains_secrets=false \
(safe_mode={safe_mode}, enterprise={enterprise})",
);
}
}
}
/// Both toggles on AND secrets present → block. Defensive: equivalent to
/// either one being on, but exhaustively pinned for the full 2x2x2 sweep
/// of (safe_mode, enterprise, contains_secrets).
#[test]
fn blocks_when_both_redactions_on_and_secrets_present() {
assert!(should_block_save_for_secrets(true, true, true));
}
@@ -1,42 +1,37 @@
use std::collections::HashMap;
use markdown_parser::parse_markdown;
use galaxy_core::ui::external_product_icon::ExternalProductIcon;
use galaxy_core::ui::icons::Icon;
use warpui::elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment,
MouseStateHandle, Padding, ParentElement, Radius, Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::ai::mcp::templatable_installation::{VariableType, VariableValue};
use crate::ai::mcp::{TemplatableMCPServer, TemplatableMCPServerManager, TemplateVariable};
use crate::appearance::Appearance;
use crate::editor::Event as EditorEvent;
use crate::editor::{EditorView, SingleLineEditorOptions};
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::settings_view::mcp_servers::style::{
INSTALLATION_MODAL_BUTTON_GAP, INSTALLATION_MODAL_BUTTON_PADDING,
INSTALLATION_MODAL_INPUT_VERTICAL_SPACING, INSTALLATION_MODAL_LABEL_VERTICAL_SPACING,
INSTALLATION_MODAL_PADDING, INSTALLATION_MODAL_TITLE_VERTICAL_SPACING,
};
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::ui_components::blended_colors;
use crate::view_components::action_button::{
ActionButton, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use crate::view_components::dropdown::{Dropdown, DropdownItem};
use galaxyui::elements::Shrinkable;
use galaxyui::fonts::{Properties, Weight};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex,
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MouseStateHandle,
ParentElement, Text,
},
platform::Cursor,
AppContext, Element, Entity, FocusContext, TypedActionView, View, ViewHandle,
};
use galaxyui::{SingletonEntity, ViewContext};
use markdown_parser::parse_markdown;
use crate::ai::mcp::{TemplatableMCPServer, TemplatableMCPServerManager, TemplateVariable};
use crate::ui_components::{
avatar::{Avatar, AvatarContent},
blended_colors,
};
use galaxyui::elements::{CornerRadius, Padding, Radius};
use galaxy_core::ui::{
color::coloru_with_opacity, external_product_icon::ExternalProductIcon, icons::Icon,
};
pub enum InstallationModalBodyEvent {
Cancel,
@@ -71,26 +66,35 @@ pub struct InstallationModalBody {
templatable_mcp_server: Option<TemplatableMCPServer>,
instructions_in_markdown: Option<String>,
variable_inputs: HashMap<String, VariableInput>,
cancel_mouse_state: MouseStateHandle,
install_mouse_state: MouseStateHandle,
cancel_button: ViewHandle<ActionButton>,
install_button: ViewHandle<ActionButton>,
close_button_mouse_state: MouseStateHandle,
is_shared: bool,
}
impl Default for InstallationModalBody {
fn default() -> Self {
Self::new()
}
}
impl InstallationModalBody {
pub fn new() -> Self {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(InstallationModalBodyAction::Cancel);
})
});
let enter_keystroke = Keystroke::parse("enter").expect("valid keystroke");
let install_button = ctx.add_typed_action_view(|ctx| {
ActionButton::new("Install", PrimaryTheme)
.with_keybinding(KeystrokeSource::Fixed(enter_keystroke), ctx)
.on_click(|ctx| {
ctx.dispatch_typed_action(InstallationModalBodyAction::Install);
})
});
Self {
templatable_mcp_server: None,
instructions_in_markdown: None,
variable_inputs: HashMap::new(),
cancel_mouse_state: Default::default(),
install_mouse_state: Default::default(),
cancel_button,
install_button,
close_button_mouse_state: Default::default(),
is_shared: false,
}
@@ -440,89 +444,21 @@ impl InstallationModalBody {
.finish()
}
fn render_action_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
let cancel_button = appearance
.ui_builder()
.button(ButtonVariant::Text, self.cancel_mouse_state.clone())
.with_text_label("Cancel".into())
.with_style(UiComponentStyles {
font_weight: Some(Weight::Bold),
font_color: Some(appearance.theme().active_ui_text_color().into()),
..Default::default()
})
.with_hovered_styles(UiComponentStyles {
font_color: Some(appearance.theme().disabled_ui_text_color().into()),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(InstallationModalBodyAction::Cancel))
.finish();
let corner_down_left_icon = Container::new(
ConstrainedBox::new(
Icon::CornerDownLeft
.to_galaxyui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_width(appearance.monospace_font_size())
.with_height(appearance.monospace_font_size())
.finish(),
)
.with_uniform_padding(2.)
.with_border(Border::all(1.).with_border_fill(coloru_with_opacity(
appearance.theme().active_ui_text_color().into(),
60,
)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let install_button_label = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline(
"Install",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_child(
Container::new(corner_down_left_icon)
.with_margin_left(8.)
.finish(),
)
.finish();
let install_button = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.install_mouse_state.clone())
.with_custom_label(install_button_label)
.with_style(UiComponentStyles {
padding: Some(Coords::uniform(5.).left(10.).right(10.)),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(InstallationModalBodyAction::Install))
.finish();
fn render_action_buttons(&self) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(cancel_button)
Container::new(ChildView::new(&self.cancel_button).finish())
.with_margin_right(INSTALLATION_MODAL_BUTTON_GAP)
.finish(),
)
.with_child(Container::new(install_button).finish())
.with_child(Container::new(ChildView::new(&self.install_button).finish()).finish())
.finish()
}
fn render_buttons_row(&self, appearance: &Appearance) -> Box<dyn Element> {
let source_indicator = Self::render_source_indicator(self.is_shared, appearance);
let action_buttons = self.render_action_buttons(appearance);
let action_buttons = self.render_action_buttons();
let spacer = Shrinkable::new(1., Container::new(Empty::new().finish()).finish()).finish();
+65 -71
View File
@@ -1,18 +1,32 @@
use crate::ai::mcp::templatable::GalleryData;
use crate::ai::mcp::MCPServerUpdate;
use crate::modal::Modal;
use crate::modal::ModalEvent;
use crate::modal::ModalViewState;
use crate::server::telemetry::{MCPTemplateInstallationSource, TelemetryEvent};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::settings_view::mcp_servers_page::InstallOrigin;
use crate::settings_view::settings_page::{
build_toggle_element, render_body_item_label, LocalOnlyIconState, ToggleState,
};
use crate::util::truncation::truncate_from_end;
use crate::view_components::DismissibleToast;
use crate::ToastStack;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::PathBuf;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use settings::ToggleableSetting as _;
use strum::IntoEnumIterator;
use uuid::Uuid;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::AppearanceEvent;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
use warpui::elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Text,
};
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::switch::SwitchStateHandle;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::mcp::gallery::MCPGalleryManagerEvent;
use crate::ai::mcp::templatable::{GalleryData, TemplatableMCPServer};
use crate::ai::mcp::templatable_manager::{
TemplatableMCPServerManager, TemplatableMCPServerManagerEvent,
};
#[cfg(feature = "local_fs")]
use crate::ai::mcp::{
// Import events for file-based manager and watcher conditionally
@@ -21,59 +35,39 @@ use crate::ai::mcp::{
FileMCPWatcher,
FileMCPWatcherEvent,
};
use crate::{
ai::mcp::{
gallery::MCPGalleryManagerEvent,
logs,
templatable::TemplatableMCPServer,
templatable_manager::{TemplatableMCPServerManager, TemplatableMCPServerManagerEvent},
FileBasedMCPManager, MCPGalleryManager, MCPProvider, TemplatableMCPServerInstallation,
},
appearance::Appearance,
cloud_object::{
model::persistence::{CloudModel, CloudModelEvent},
GenericStringObjectFormat, JsonObjectType,
},
drive::CloudObjectTypeAndId,
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
pane_group::Direction,
search_bar::SearchBar,
settings_view::mcp_servers::{
server_card::{
ServerCardEvent, ServerCardOptions, ServerCardStatus, ServerCardView, TitleChip,
},
style,
update_modal::{UpdateModalBody, UpdateModalBodyEvent},
ServerCardItemId,
},
ui_components::blended_colors,
view_components::action_button::{ActionButton, NakedTheme},
workflows::local_workflows::tail_command_for_shell,
workspace::Workspace,
workspaces::user_workspaces::UserWorkspaces,
use crate::ai::mcp::{
logs, FileBasedMCPManager, MCPGalleryManager, MCPProvider, MCPServerUpdate,
TemplatableMCPServerInstallation,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::{appearance::AppearanceEvent, theme::color::internal_colors, Icon};
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Text,
},
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{GenericStringObjectFormat, JsonObjectType};
use crate::drive::CloudObjectTypeAndId;
use crate::editor::{
EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use settings::ToggleableSetting as _;
use std::cmp::Ordering;
use std::{collections::HashMap, path::PathBuf};
use strum::IntoEnumIterator;
use uuid::Uuid;
use crate::modal::{Modal, ModalEvent, ModalViewState};
use crate::pane_group::Direction;
use crate::search_bar::SearchBar;
use crate::server::telemetry::{MCPTemplateInstallationSource, TelemetryEvent};
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::settings_view::mcp_servers::server_card::{
ServerCardEvent, ServerCardOptions, ServerCardStatus, ServerCardView, TitleChip,
};
use crate::settings_view::mcp_servers::update_modal::{UpdateModalBody, UpdateModalBodyEvent};
use crate::settings_view::mcp_servers::{style, ServerCardItemId};
use crate::settings_view::mcp_servers_page::InstallOrigin;
use crate::settings_view::settings_page::{
build_toggle_element, render_body_item_label, LocalOnlyIconState, ToggleState,
};
use crate::ui_components::blended_colors;
use crate::util::truncation::truncate_from_end;
use crate::view_components::action_button::{ActionButton, NakedTheme};
use crate::view_components::DismissibleToast;
use crate::workflows::local_workflows::tail_command_for_shell;
use crate::workspace::Workspace;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::ToastStack;
const DESCRIPTION_TEXT: &str = "Add MCP servers to extend the Warp Agent's capabilities. MCP servers expose data sources or tools to agents through a standardized interface, essentially acting like plugins. Add a custom server, or use the presets to get started with popular servers. You can also find team servers that have been shared with you here. ";
@@ -187,7 +181,7 @@ impl MCPServersListPageView {
}
});
let update_modal_body = ctx.add_typed_action_view(|_ctx| UpdateModalBody::new());
let update_modal_body = ctx.add_typed_action_view(UpdateModalBody::new);
ctx.subscribe_to_view(&update_modal_body, |me, _, event, ctx| {
me.handle_update_modal_body_event(event, ctx);
});
@@ -778,7 +772,7 @@ impl MCPServersListPageView {
} else {
self.update_modal_state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.set_installation(installation_uuid, server_name, available_updates);
body.set_installation(installation_uuid, server_name, available_updates, ctx);
ctx.notify();
});
});
@@ -997,8 +991,8 @@ impl MCPServersListPageView {
match event {
UpdateModalBodyEvent::Cancel => {
self.update_modal_state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, _ctx| {
body.clear();
modal.body().update(ctx, |body, ctx| {
body.clear(ctx);
});
});
self.update_modal_state.close();
@@ -1015,8 +1009,8 @@ impl MCPServersListPageView {
};
self.process_server_update(*installation_uuid, update.clone(), ctx);
self.update_modal_state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, _ctx| {
body.clear();
modal.body().update(ctx, |body, ctx| {
body.clear(ctx);
});
});
self.update_modal_state.close();
+2 -4
View File
@@ -1,7 +1,5 @@
use std::{
cmp::Ordering,
fmt::{Display, Formatter, Result},
};
use std::cmp::Ordering;
use std::fmt::{Display, Formatter, Result};
use uuid::Uuid;
@@ -1,45 +1,33 @@
use galaxy_core::{
features::FeatureFlag,
ui::{
external_product_icon::ExternalProductIcon,
icons::{Icon, ICON_DIMENSIONS},
theme::{color::internal_colors, AnsiColorIdentifier},
},
};
use galaxyui::{
accessibility::ActionAccessibilityContent,
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex,
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize,
MouseState, MouseStateHandle, Padding, ParentElement, Radius, Text, Wrap,
},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
chip::Chip,
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use crate::{
ai::mcp::{
templatable::CloudTemplatableMCPServer, MCPServerState, TemplatableMCPServerManager,
},
appearance::Appearance,
cloud_object::CloudObject,
settings_view::mcp_servers::{style, ServerCardItemId},
ui_components::{
avatar::{Avatar, AvatarContent, StatusElementTypes},
blended_colors,
buttons::icon_button,
red_notification_dot::RedNotificationDot,
},
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::external_product_icon::ExternalProductIcon;
use galaxy_core::ui::icons::{Icon, ICON_DIMENSIONS};
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxyui::accessibility::ActionAccessibilityContent;
use galaxyui::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex,
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize,
MouseState, MouseStateHandle, Padding, ParentElement, Radius, Text, Wrap,
};
use galaxyui::fonts::Weight;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::chip::Chip;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::ai::mcp::templatable::CloudTemplatableMCPServer;
use crate::ai::mcp::{MCPServerState, TemplatableMCPServerManager};
use crate::appearance::Appearance;
use crate::cloud_object::{CloudObject, CloudObjectUuidLookup as _};
use crate::settings_view::mcp_servers::{style, ServerCardItemId};
use crate::ui_components::avatar::{Avatar, AvatarContent, StatusElementTypes};
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::red_notification_dot::RedNotificationDot;
/// A chip displayed inline with the server card title, optionally with a leading icon.
#[derive(Debug, Clone)]
+2 -4
View File
@@ -1,8 +1,6 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
fonts::Weight,
ui_components::components::{Coords, UiComponentStyles},
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
pub const ICON_MARGIN: f32 = 8.;
pub const HEADER_FONT_SIZE: f32 = 18.;
+71 -106
View File
@@ -1,3 +1,21 @@
use chrono::{Local, TimeZone};
use uuid::Uuid;
use galaxy_core::ui::external_product_icon::ExternalProductIcon;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use warpui::elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Flex, Hoverable, MainAxisAlignment, MouseStateHandle, Padding, ParentElement, Radius,
Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::mcp::{Author, MCPServerUpdate};
use crate::appearance::Appearance;
use crate::settings_view::mcp_servers::style::{
@@ -6,25 +24,9 @@ use crate::settings_view::mcp_servers::style::{
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::ui_components::blended_colors;
use crate::util::time_format::format_approx_duration_from_now;
use chrono::{Local, TimeZone};
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::external_product_icon::ExternalProductIcon;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{Align, Empty, Padding, Shrinkable};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::SingletonEntity;
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Text,
},
platform::Cursor,
AppContext, Element, Entity, TypedActionView, View, ViewContext,
use crate::view_components::action_button::{
ActionButton, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use uuid::Uuid;
pub enum UpdateModalBodyEvent {
Cancel,
@@ -41,21 +43,47 @@ pub enum UpdateModalBodyAction {
SelectOption(usize),
}
#[derive(Default)]
pub struct UpdateModalBody {
installation_uuid: Option<Uuid>,
server_name: Option<String>,
update_options: Vec<MCPServerUpdate>,
selected_updates: Vec<bool>,
cancel_mouse_state: MouseStateHandle,
update_mouse_state: MouseStateHandle,
cancel_button: ViewHandle<ActionButton>,
update_button: ViewHandle<ActionButton>,
close_button_mouse_state: MouseStateHandle,
option_mouse_states: Vec<MouseStateHandle>,
}
impl UpdateModalBody {
pub fn new() -> Self {
Default::default()
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(UpdateModalBodyAction::Cancel);
})
});
let enter_keystroke = Keystroke::parse("enter").expect("valid keystroke");
let update_button = ctx.add_typed_action_view(|ctx| {
let mut button = ActionButton::new("Update", PrimaryTheme)
.with_keybinding(KeystrokeSource::Fixed(enter_keystroke), ctx)
.on_click(|ctx| {
ctx.dispatch_typed_action(UpdateModalBodyAction::Update);
});
// Initial state has no rows selected, so the button starts disabled.
button.set_disabled(true, ctx);
button
});
Self {
installation_uuid: None,
server_name: None,
update_options: vec![],
selected_updates: vec![],
cancel_button,
update_button,
close_button_mouse_state: Default::default(),
option_mouse_states: vec![],
}
}
pub fn set_installation(
@@ -63,6 +91,7 @@ impl UpdateModalBody {
installation_uuid: Uuid,
server_name: String,
update_options: Vec<MCPServerUpdate>,
ctx: &mut ViewContext<Self>,
) {
self.installation_uuid = Some(installation_uuid);
self.server_name = Some(server_name);
@@ -71,14 +100,25 @@ impl UpdateModalBody {
self.option_mouse_states = (0..self.update_options.len())
.map(|_| MouseStateHandle::default())
.collect();
// No rows are selected yet, so the Update button must start disabled.
self.refresh_update_button_disabled(ctx);
}
pub fn clear(&mut self) {
pub fn clear(&mut self, ctx: &mut ViewContext<Self>) {
self.installation_uuid = None;
self.server_name = None;
self.update_options = vec![];
self.selected_updates = vec![];
self.option_mouse_states = vec![];
self.refresh_update_button_disabled(ctx);
}
/// Sync the Update button's disabled state with the current selection.
fn refresh_update_button_disabled(&mut self, ctx: &mut ViewContext<Self>) {
let has_selection = self.selected_updates.iter().any(|&x| x);
self.update_button.update(ctx, |button, ctx| {
button.set_disabled(!has_selection, ctx);
});
}
fn render_title(&self, appearance: &Appearance) -> Box<dyn Element> {
@@ -298,97 +338,20 @@ impl UpdateModalBody {
.finish()
}
fn render_action_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
let cancel_button = appearance
.ui_builder()
.button(ButtonVariant::Text, self.cancel_mouse_state.clone())
.with_text_label("Cancel".into())
.with_style(UiComponentStyles {
font_weight: Some(Weight::Bold),
font_color: Some(appearance.theme().active_ui_text_color().into()),
..Default::default()
})
.with_hovered_styles(UiComponentStyles {
font_color: Some(appearance.theme().disabled_ui_text_color().into()),
..Default::default()
})
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(UpdateModalBodyAction::Cancel))
.finish();
let corner_down_left_icon = Container::new(
ConstrainedBox::new(
Icon::CornerDownLeft
.to_galaxyui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_width(appearance.monospace_font_size())
.with_height(appearance.monospace_font_size())
.finish(),
)
.with_uniform_padding(2.)
.with_border(Border::all(1.).with_border_fill(coloru_with_opacity(
appearance.theme().active_ui_text_color().into(),
60,
)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish();
let update_button_label = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline(
"Update",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_child(
Container::new(corner_down_left_icon)
.with_margin_left(8.)
.finish(),
)
.finish();
let mut update_button_builder = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.update_mouse_state.clone())
.with_custom_label(update_button_label)
.with_style(UiComponentStyles {
padding: Some(Coords::uniform(5.).left(10.).right(10.)),
..Default::default()
});
// Disable the update button if no updates are selected
let has_selection = self.selected_updates.iter().any(|&x| x);
if !has_selection {
update_button_builder = update_button_builder.disabled();
}
let update_button = update_button_builder
.build()
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(UpdateModalBodyAction::Update))
.finish();
fn render_action_buttons(&self) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(cancel_button)
Container::new(ChildView::new(&self.cancel_button).finish())
.with_margin_right(INSTALLATION_MODAL_BUTTON_GAP)
.finish(),
)
.with_child(Container::new(update_button).finish())
.with_child(Container::new(ChildView::new(&self.update_button).finish()).finish())
.finish()
}
fn render_buttons_row(&self, appearance: &Appearance) -> Box<dyn Element> {
let action_buttons = self.render_action_buttons(appearance);
let action_buttons = self.render_action_buttons();
let spacer = Shrinkable::new(1., Container::new(Empty::new().finish()).finish()).finish();
@@ -476,9 +439,11 @@ impl TypedActionView for UpdateModalBody {
}
}
UpdateModalBodyAction::SelectOption(index) => {
// Toggle the selection at the given index
// Toggle the selection at the given index, then sync the
// Update button's disabled state with the new selection.
if let Some(selected) = self.selected_updates.get_mut(*index) {
*selected = !*selected;
self.refresh_update_button_disabled(ctx);
ctx.notify();
}
}
+29 -27
View File
@@ -1,33 +1,36 @@
use std::collections::HashMap;
use uuid::Uuid;
use galaxyui::elements::{ChildView, Container};
use galaxyui::ui_components::components::{Coords, UiComponentStyles};
use galaxyui::{
elements::{ChildView, Container},
ui_components::components::{Coords, UiComponentStyles},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use std::collections::HashMap;
use uuid::Uuid;
use crate::{
ai::mcp::{
gallery::MCPGalleryManager, templatable_installation::VariableValue, FileBasedMCPManager,
TemplatableMCPServer, TemplatableMCPServerInstallation, TemplatableMCPServerManager,
},
appearance::Appearance,
cloud_object::Space,
modal::{Modal, ModalViewState},
server::cloud_objects::update_manager::InitiatedBy,
settings_view::{
mcp_servers::{
edit_page::{MCPServersEditPageView, MCPServersEditPageViewEvent},
installation_modal::{InstallationModalBody, InstallationModalBodyEvent},
list_page::{MCPServersListPageView, MCPServersListPageViewEvent},
style, ServerCardItemId,
},
settings_page::{MatchData, PageType, SettingsPageMeta, SettingsWidget},
SettingsSection,
},
view_components::DismissibleToast,
workspace::ToastStack,
use crate::ai::mcp::gallery::MCPGalleryManager;
use crate::ai::mcp::templatable_installation::VariableValue;
use crate::ai::mcp::{
FileBasedMCPManager, TemplatableMCPServer, TemplatableMCPServerInstallation,
TemplatableMCPServerManager,
};
use crate::appearance::Appearance;
use crate::cloud_object::Space;
use crate::modal::{Modal, ModalViewState};
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::settings_view::mcp_servers::edit_page::{
MCPServersEditPageView, MCPServersEditPageViewEvent,
};
use crate::settings_view::mcp_servers::installation_modal::{
InstallationModalBody, InstallationModalBodyEvent,
};
use crate::settings_view::mcp_servers::list_page::{
MCPServersListPageView, MCPServersListPageViewEvent,
};
use crate::settings_view::mcp_servers::{style, ServerCardItemId};
use crate::settings_view::settings_page::{MatchData, PageType, SettingsPageMeta, SettingsWidget};
use crate::settings_view::SettingsSection;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
/// Describes where an MCP install request originated.
///
@@ -83,8 +86,7 @@ impl MCPServersSettingsPageView {
me.handle_edit_view_event(event, ctx);
});
let installation_modal_body =
ctx.add_typed_action_view(|_ctx| InstallationModalBody::new());
let installation_modal_body = ctx.add_typed_action_view(InstallationModalBody::new);
ctx.subscribe_to_view(&installation_modal_body, |me, _, event, ctx| {
me.handle_installation_modal_body_event(event, ctx);
});
+283 -75
View File
@@ -1,55 +1,15 @@
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::server::telemetry::MCPServerCollectionPaneEntrypoint;
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
use crate::TelemetryEvent;
use crate::{
ai::execution_profiles::profiles::ClientProfileId,
appearance::Appearance,
editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextColors, TextOptions,
},
menu::{self, Menu, MenuItem, MenuItemFields},
pane_group::{
pane::view, BackingView, Direction, PaneConfiguration, PaneEvent, SplitPaneState,
},
settings::{AISettings, BlockVisibilitySettings, SettingsFileError},
settings_view::mcp_servers_page::MCPServersSettingsPageEvent,
terminal::{model::blockgrid::BlockGrid, SizeInfo},
ui_components::icons,
util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction},
view_components::ToastFlavor,
workspace::WorkspaceAction,
GlobalResourceHandlesProvider,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use about_page::AboutPageView;
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
use billing_and_usage_page::{BillingAndUsagePageEvent, BillingAndUsagePageView};
use code_page::CodeSubpage;
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent};
use billing_and_usage_dispatch::BillingAndUsageDispatchView;
use billing_and_usage_page::BillingAndUsagePageEvent;
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
use environments_page::EnvironmentsPageView;
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::{
channel::ChannelState, context_flag::ContextFlag, features::FeatureFlag,
settings::ToggleableSetting as _, ui::theme::color::internal_colors,
};
use galaxy_editor::editor::NavigationKey;
use galaxyui::Element;
use galaxyui::{
elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle,
ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, Empty, EventHandler, Expanded, Fill, Flex, MainAxisSize,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition,
ScrollbarWidth, Shrinkable, Stack, Text,
},
fonts::{Properties, Weight},
id,
keymap::{ContextPredicate, EnabledPredicate, FixedBinding},
Action, AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateView as _,
View, ViewContext, ViewHandle,
};
use itertools::Itertools as _;
use keybindings::KeybindingsView;
use main_page::{MainPageAction, MainSettingsPageEvent, MainSettingsPageView};
@@ -57,15 +17,60 @@ use mcp_servers_page::MCPServersSettingsPageView;
use nav::{SettingsNavItem, SettingsUmbrella};
use pathfinder_geometry::vector::Vector2F;
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
use referrals_page::{ReferralsPageEvent, ReferralsPageView};
use scripting_page::ScriptingSettingsPageView;
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
use settings_page::{
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
HEADER_PADDING,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use show_blocks_view::{ShowBlocksEvent, ShowBlocksView};
use teams_page::{TeamsPageView, TeamsPageViewEvent};
use galaxy_core::channel::ChannelState;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::settings::ToggleableSetting as _;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::editor::NavigationKey;
use warpify_page::{WarpifyPageAction, WarpifyPageView};
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty,
EventHandler, Expanded, Fill, Flex, MainAxisSize, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollbarWidth, Shrinkable, Stack,
Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::{ContextPredicate, EnabledPredicate, FixedBinding};
use galaxyui::{
id, Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView,
UpdateView as _, View, ViewContext, ViewHandle,
};
use self::telemetry::SettingsTelemetryEvent;
use crate::ai::custom_model_routers::CustomModelRouter;
use crate::ai::execution_profiles::profiles::ClientProfileId;
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextColors, TextOptions,
};
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, Direction, PaneConfiguration, PaneEvent, SplitPaneState};
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::MCPServerCollectionPaneEntrypoint;
use crate::settings::{AISettings, BlockVisibilitySettings, SettingsFileError};
use crate::settings_view::mcp_servers_page::{MCPServersSettingsPage, MCPServersSettingsPageEvent};
use crate::terminal::model::blockgrid::BlockGrid;
use crate::terminal::SizeInfo;
use crate::ui_components::icons;
use crate::util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction};
use crate::view_components::ToastFlavor;
use crate::workspace::WorkspaceAction;
use crate::{GlobalResourceHandlesProvider, TelemetryEvent};
mod about_page;
mod admin_actions;
@@ -73,14 +78,19 @@ mod agent_assisted_environment_modal;
mod ai_page;
mod appearance_page;
mod billing_and_usage;
mod billing_and_usage_dispatch;
mod billing_and_usage_page;
mod billing_and_usage_page_v2;
mod code_page;
pub(crate) mod custom_inference_modal;
mod custom_router_view;
mod delete_environment_confirmation_dialog;
mod directory_color_add_picker;
pub(crate) mod environments_page;
mod execution_profile_view;
mod features;
mod features_page;
pub(crate) mod handoff_environment_creation_modal;
pub mod keybindings;
mod main_page;
pub mod mcp_servers;
@@ -91,6 +101,10 @@ mod platform;
mod platform_page;
mod privacy;
mod privacy_page;
mod referrals_page;
mod remove_custom_endpoint_confirmation_dialog;
mod scripting_page;
mod set_default_model_modal;
mod settings_file_footer;
pub(crate) mod settings_page;
mod show_blocks_view;
@@ -104,6 +118,7 @@ mod warpify_page;
#[cfg(not(target_family = "wasm"))]
pub(crate) use ai_page::cli_agent_settings_widget_id;
pub(crate) use ai_page::custom_model_routers_widget_id;
pub use billing_and_usage_page::create_discount_badge;
pub use code_page::CodeSettingsPageView;
pub use features_page::FeaturesPageAction;
@@ -153,7 +168,55 @@ pub(super) fn editor_text_colors(appearance: &Appearance) -> TextColors {
}
}
#[derive(PartialEq, Eq)]
/// Small inline pill rendered next to a settings label to mark a feature as beta.
/// Used for experimental features (i.e. AsyncFind) that are enabled for Friends of Warp (i.e. Dogfood/Preview) and toggleable by others.
pub(super) fn render_beta_chip(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let chip_color = theme.sub_text_color(theme.surface_3()).into_solid();
Container::new(
Text::new_inline("BETA", appearance.ui_font_family(), 10.)
.with_color(chip_color)
.finish(),
)
.with_background(theme.surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.with_horizontal_padding(4.)
.with_vertical_padding(1.)
.with_margin_left(8.)
.finish()
}
/// Renders a horizontal row of pill-shaped chips for model labels.
/// Used by custom inference endpoint cards and the remove confirmation dialog.
pub(super) fn render_model_chips(
labels: impl IntoIterator<Item = String>,
appearance: &Appearance,
text_color: galaxy_core::ui::theme::Fill,
) -> Box<dyn Element> {
use warpui::ui_components::chip::Chip;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
let theme = appearance.theme();
let chip_border = internal_colors::neutral_4(theme).into();
let chip_style = UiComponentStyles {
background: None,
border_color: Some(chip_border),
border_width: Some(1.),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.ui_font_size()),
font_color: Some(text_color.into_solid()),
..Default::default()
};
let mut chips = Flex::row().with_spacing(8.);
for label in labels {
chips.add_child(Chip::new(label, chip_style).build().finish());
}
chips.finish()
}
#[derive(PartialEq)]
pub enum SettingsViewEvent {
Pane(PaneEvent),
StartResize,
@@ -167,6 +230,8 @@ pub enum SettingsViewEvent {
},
OpenAIFactCollection,
OpenMCPServerCollection,
OpenCustomRouterEditor(Option<CustomModelRouter>),
OpenCustomRouterFile(PathBuf),
OpenExecutionProfileEditor(ClientProfileId),
OpenLspLogs {
log_path: PathBuf,
@@ -188,6 +253,10 @@ pub enum SettingsSection {
Features,
Keybindings,
Privacy,
Referrals,
Scripting,
SharedBlocks,
Teams,
WarpDrive,
Warpify,
/// Internal backing-page identifier for AISettingsPageView. Multiple subpages
@@ -224,17 +293,19 @@ pub enum SettingsSection {
Referrals,
}
use crate::util::bindings::custom_tag_to_keystroke;
use std::fmt::{self, Display};
use crate::util::bindings::custom_tag_to_keystroke;
impl Display for SettingsSection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SettingsSection::BillingAndUsage => write!(f, "Billing and usage"),
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
SettingsSection::MCPServers => write!(f, "MCP Servers"),
SettingsSection::Scripting => write!(f, "Scripting"),
SettingsSection::WarpDrive => write!(f, "Galaxy Drive"),
SettingsSection::WarpAgent => write!(f, "Galaxy Agent"),
SettingsSection::WarpAgent => write!(f, "Warp Agent"),
SettingsSection::AgentProfiles => write!(f, "Profiles"),
SettingsSection::AgentMCPServers => write!(f, "MCP servers"),
SettingsSection::Knowledge => write!(f, "Knowledge"),
@@ -322,8 +393,12 @@ impl FromStr for SettingsSection {
"Features" => Ok(Self::Features),
"Keyboard shortcuts" => Ok(Self::Keybindings),
"Privacy" => Ok(Self::Privacy),
"Warpify" | "Wormhole" => Ok(Self::Warpify),
"WarpDrive" | "Warp Drive" | "Galaxy Drive" => Ok(Self::WarpDrive),
"Referrals" => Ok(Self::Referrals),
"Scripting" => Ok(Self::Scripting),
"Shared blocks" => Ok(Self::SharedBlocks),
"Teams" => Ok(Self::Teams),
"Warpify" => Ok(Self::Warpify),
"WarpDrive" | "Galaxy Drive" => Ok(Self::WarpDrive),
// This page was called "Oz" at one point, keep for backward compatibility.
"Oz" | "Warp Agent" | "Galaxy Agent" => Ok(Self::WarpAgent),
"Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles),
@@ -343,6 +418,29 @@ impl FromStr for SettingsSection {
}
}
/// Resolves a stable, friendly deeplink slug (used by
/// `warp://settings?widget=<slug>`) to the settings page and `&'static str`
/// widget id it should scroll to.
///
/// Only allowlisted widgets are linkable, so the public URL contract stays
/// stable and internal widget identifiers (Rust type names) are not exposed.
/// Add an entry here to make a new widget deep-linkable.
pub fn settings_widget_deeplink_target(slug: &str) -> Option<(SettingsSection, &'static str)> {
match slug {
"global_hotkey" => Some((
SettingsSection::Features,
features_page::global_hotkey_widget_id(),
)),
"custom_router" => Some((SettingsSection::WarpAgent, custom_model_routers_widget_id())),
#[cfg(not(target_family = "wasm"))]
"cli_agents" => Some((
SettingsSection::ThirdPartyCLIAgents,
cli_agent_settings_widget_id(),
)),
_ => None,
}
}
pub struct DisplayCount(pub usize);
impl Entity for DisplayCount {
@@ -375,12 +473,17 @@ pub mod flags {
pub const QUAKE_WINDOW_OPEN_FLAG: &str = "Quake_Window_Open";
pub const EXTRA_META_KEYS_RIGHT_CONTEXT_FLAG: &str = "Extra_Meta_Keys_Right";
pub const EXTRA_META_KEYS_LEFT_CONTEXT_FLAG: &str = "Extra_Meta_Keys_Left";
pub const MOUSE_REPORTING_CONTEXT_FLAG: &str = "Mouse_Reporting";
pub const SCROLL_REPORTING_CONTEXT_FLAG: &str = "Scroll_Reporting";
pub const FOCUS_REPORTING_CONTEXT_FLAG: &str = "Focus_Reporting";
#[deprecated = "Use `SSH_TMUX_WRAPPER_CONTEXT_FLAG` for new ssh warpification logic"]
pub const LEGACY_SSH_WRAPPER_CONTEXT_FLAG: &str = "SSH_Wrapper";
pub const SSH_TMUX_WRAPPER_CONTEXT_FLAG: &str = "SSH_Tmux_Wrapper";
pub const SSH_REUSE_CONTROL_MASTER_CONTEXT_FLAG: &str = "SSH_Reuse_Control_Master";
pub const SSH_WARPIFICATION_CONTEXT_FLAG: &str = "SSH_Warpification";
pub const NOTIFICATIONS_CONTEXT_FLAG: &str = "Notifications_Enabled";
pub const LONG_RUNNING_NOTIFICATIONS_FLAG: &str = "Long_Running_Notifications";
pub const AGENT_TASK_COMPLETED_NOTIFICATIONS_FLAG: &str = "Agent_Task_Completed_Notifications";
pub const NEEDS_ATTENTION_NOTIFICATIONS_FLAG: &str = "Needs_Attention_Notifications";
pub const NOTIFICATION_SOUND_FLAG: &str = "Notification_Sound";
pub const AGENT_IN_APP_NOTIFICATIONS_FLAG: &str = "Agent_In_App_Notifications";
pub const LINK_TOOLTIP_CONTEXT_FLAG: &str = "Link_Tooltip";
pub const COMPACT_MODE_CONTEXT_FLAG: &str = "Compact_Mode_Enabled";
pub const CURSOR_BLINK_CONTEXT_FLAG: &str = "Cursor_Blink_Enabled";
@@ -399,7 +502,16 @@ pub mod flags {
pub const SETTINGS_SYNC_FLAG: &str = "settings_sync";
pub const SAFE_MODE_FLAG: &str = "safe_mode";
pub const CRASH_REPORTING_FLAG: &str = "crash_reporting";
pub const CLOUD_CONVERSATION_STORAGE_FLAG: &str = "Cloud_Conversation_Storage_Enabled";
pub const CLOUD_CONVERSATION_STORAGE_EDITABLE_FLAG: &str =
"Cloud_Conversation_Storage_Editable";
pub const DIM_INACTIVE_PANES_FLAG: &str = "Dim_Inactive_Panes";
pub const OPEN_WINDOWS_AT_CUSTOM_SIZE_FLAG: &str = "Open_Windows_At_Custom_Size";
pub const WINDOW_BLUR_TEXTURE_FLAG: &str = "Window_Blur_Texture";
pub const LEFT_PANEL_VISIBILITY_ACROSS_TABS_FLAG: &str = "Left_Panel_Visibility_Across_Tabs";
pub const MATCH_AI_FONT_TO_TERMINAL_FONT_FLAG: &str = "Match_AI_Font_To_Terminal_Font";
pub const MATCH_NOTEBOOK_FONT_SIZE_TO_TERMINAL_FONT_SIZE_FLAG: &str =
"Match_Notebook_Font_Size_To_Terminal_Font_Size";
pub const QUIT_WARNING_MODAL: &str = "Quit_Warning_Modal";
pub const BLOCK_DIVIDERS_CONTEXT_FLAG: &str = "Block_Dividers_Enabled";
@@ -408,7 +520,15 @@ pub mod flags {
pub const ACTIVATION_HOTKEY_FLAG: &str = "Activation_Hotkey_Enabled";
pub const TAB_INDICATORS_FLAG: &str = "Tab_Indicators_Enabled";
pub const SHOW_CODE_REVIEW_BUTTON_FLAG: &str = "Show_Code_Review_Button_Enabled";
pub const SHOW_CODE_REVIEW_DIFF_STATS_FLAG: &str = "Show_Code_Review_Diff_Stats_Enabled";
pub const AUTO_OPEN_CODE_REVIEW_PANE_FLAG: &str = "Auto_Open_Code_Review_Pane_Enabled";
pub const USE_VERTICAL_TABS_FLAG: &str = "Use_Vertical_Tabs";
pub const PRESERVE_ACTIVE_TAB_COLOR_FLAG: &str = "Preserve_Active_Tab_Color";
pub const SHOW_VERTICAL_TAB_PANEL_IN_RESTORED_WINDOWS_FLAG: &str =
"Show_Vertical_Tab_Panel_In_Restored_Windows";
pub const USE_LATEST_USER_PROMPT_AS_CONVERSATION_TITLE_IN_TAB_NAMES_FLAG: &str =
"Use_Latest_User_Prompt_As_Conversation_Title_In_Tab_Names";
pub const ALT_SCREEN_PADDING_FLAG: &str = "Alt_Screen_Padding";
pub const SESSION_CONFIG_TAB_CONFIG_CHIP_OPEN: &str = "Session_Config_Tab_Config_Chip_Open";
pub const FOCUS_PANES_ON_HOVER_CONTEXT_FLAG: &str = "Focus_Panes_On_Hover";
pub const HIDE_WORKSPACE_DECORATIONS_CONTEXT_FLAG: &str = "Hide_Workspace_Decorations";
@@ -425,10 +545,29 @@ pub mod flags {
pub const THINKING_DISPLAY_SHOW_AND_COLLAPSE: &str = "Thinking_Display_ShowAndCollapse";
pub const THINKING_DISPLAY_ALWAYS_SHOW: &str = "Thinking_Display_AlwaysShow";
pub const THINKING_DISPLAY_NEVER_SHOW: &str = "Thinking_Display_NeverShow";
pub const ORCHESTRATION_MESSAGE_DISPLAY_SHOW_AND_COLLAPSE: &str =
"Orchestration_Message_Display_ShowAndCollapse";
pub const ORCHESTRATION_MESSAGE_DISPLAY_ALWAYS_SHOW: &str =
"Orchestration_Message_Display_AlwaysShow";
pub const ORCHESTRATION_MESSAGE_DISPLAY_ALWAYS_COLLAPSE: &str =
"Orchestration_Message_Display_AlwaysCollapse";
pub const PROMPT_SUBMISSION_INTERRUPT: &str = "Prompt_Submission_Interrupt";
pub const PROMPT_SUBMISSION_QUEUE: &str = "Prompt_Submission_Queue";
pub const LRC_SUBMISSION_SEND_IMMEDIATELY: &str = "LRC_Submission_Send_Immediately";
pub const LRC_SUBMISSION_QUEUE_UNTIL_COMMAND_COMPLETES: &str =
"LRC_Submission_Queue_Until_Command_Completes";
pub const SHOW_TERMINAL_INPUT_MESSAGE_LINE_FLAG: &str = "Show_Terminal_Input_Message_Line";
pub const PRESERVE_INPUT_FOCUS_ON_BLOCK_SELECTION_FLAG: &str =
"Preserve_Input_Focus_On_Block_Selection";
pub const SLASH_COMMANDS_IN_TERMINAL_FLAG: &str = "Slash_Commands_In_Terminal";
pub const AT_CONTEXT_MENU_IN_TERMINAL_FLAG: &str = "At_Context_Menu_In_Terminal";
pub const OUTLINE_CODEBASE_SYMBOLS_FOR_AT_CONTEXT_MENU_FLAG: &str =
"Outline_Codebase_Symbols_For_At_Context_Menu";
pub const AUTOSUGGESTIONS_ENABLED_FLAG: &str = "Autosuggestions_Enabled";
pub const AUTOSUGGESTION_KEYBINDING_HINT_FLAG: &str = "Hide_Autosuggestion_Keybinding_Hint";
pub const SHOW_AUTOSUGGESTION_IGNORE_BUTTON_FLAG: &str = "Show_Autosuggestion_Ignore_Button";
pub const SHOW_TERMINAL_ZERO_STATE_BLOCK_FLAG: &str = "Show_Terminal_Zero_State_Block";
pub const GLOBAL_WORKFLOWS_IN_COMMAND_SEARCH_FLAG: &str = "Global_Workflows_In_Command_Search";
pub const PREFER_LOW_POWER_GPU_FLAG: &str = "Prefer_Low_Power_GPU";
pub const INITIALIZATION_BLOCK_FLAG: &str = "Initialization_Block_Visible";
pub const IN_BAND_COMMAND_BLOCKS_FLAG: &str = "In_Band_Command_Blocks_Visible";
@@ -443,6 +582,14 @@ pub mod flags {
pub const CODE_SUGGESTIONS_FLAG: &str = "Code_Suggestions";
pub const NATURAL_LANGUAGE_AUTOSUGGESTIONS_FLAG: &str = "Natural_Language_Autosuggestions";
pub const SHARED_BLOCK_TITLE_GENERATION_FLAG: &str = "Shared_Block_Title_Generation";
pub const GIT_OPERATIONS_AUTOGEN_FLAG: &str = "Git_Operations_Autogen";
pub const INCLUDE_AGENT_COMMANDS_IN_HISTORY_FLAG: &str = "Include_Agent_Commands_In_History";
pub const AI_RULES_FLAG: &str = "AI_Rules";
pub const SUGGESTED_RULES_FLAG: &str = "Suggested_Rules";
pub const WARP_DRIVE_CONTEXT_FLAG: &str = "Warp_Drive_Context";
pub const FILE_BASED_MCP_FLAG: &str = "File_Based_MCP";
pub const WARP_CREDIT_FALLBACK_FLAG: &str = "Warp_Credit_Fallback";
pub const SHOW_BASE_MODEL_PICKER_IN_PROMPT_FLAG: &str = "Show_Base_Model_Picker_In_Prompt";
pub const DEBUG_SHOW_MEMORY_STATS_FLAG: &str = "Debug_Memory_Statistics";
pub const ALLOW_NATIVE_WAYLAND: &str = "Allow_Native_Wayland";
pub const IS_ANY_AI_ENABLED: &str = "IsAnyAIEnabled";
@@ -462,6 +609,10 @@ pub mod flags {
/// When set, ctrl-enter should accept a prompt suggestion rather than insert a newline.
/// This flag is set by the terminal Input when there's a pending passive code diff.
pub const CTRL_ENTER_ACCEPTS_PROMPT_SUGGESTION: &str = "CtrlEnterAcceptsPromptSuggestion";
/// When set, the terminal input owns Page Up / Page Down so the editor's fixed bindings
/// should not match.
pub const TERMINAL_INPUT_PAGE_KEYS_HANDLED_BY_INPUT: &str =
"TerminalInputPageKeysHandledByInput";
pub const HAS_PENDING_PROMPT_SUGGESTION: &str = "HasPendingPromptSuggestion";
pub const ACTIVE_AGENT_VIEW: &str = "ActiveAgentView";
pub const ACTIVE_INLINE_AGENT_VIEW: &str = "ActiveInlineAgentView";
@@ -476,11 +627,16 @@ pub mod flags {
pub const CLI_AGENT_RICH_INPUT_OPEN: &str = "CLIAgentRichInputOpen";
pub const CLI_AGENT_FOOTER_ENABLED: &str = "CLIAgentFooterEnabled";
pub const CLI_AGENT_RICH_INPUT_CHIP_ENABLED: &str = "CLIAgentRichInputChipEnabled";
pub const AUTO_TOGGLE_RICH_INPUT_FLAG: &str = "AutoToggleRichInput";
pub const AUTO_OPEN_RICH_INPUT_ON_CLI_AGENT_START_FLAG: &str =
"AutoOpenRichInputOnCLIAgentStart";
pub const AUTO_DISMISS_RICH_INPUT_AFTER_SUBMIT_FLAG: &str = "AutoDismissRichInputAfterSubmit";
pub const ENABLE_WARP_DRIVE: &str = "EnableWarpDrive";
// Tools panel settings
pub const SHOW_CONVERSATION_HISTORY: &str = "ShowConversationHistory";
pub const SHOW_PROJECT_EXPLORER: &str = "ShowProjectExplorer";
pub const SHOW_GLOBAL_SEARCH: &str = "ShowGlobalSearch";
pub const SHOW_HIDDEN_FILES: &str = "ShowHiddenFiles";
}
pub fn init_actions_from_parent_view<T: Action + Clone>(
@@ -495,6 +651,7 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
privacy_page::init_actions_from_parent_view(app, context, builder);
ai_page::init_actions_from_parent_view(app, context, builder);
code_page::init_actions_from_parent_view(app, context, builder);
warp_drive_page::init_actions_from_parent_view(app, context, builder);
if ChannelState::enable_debug_features() || cfg!(windows) {
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
@@ -696,16 +853,9 @@ impl<T: Action + Clone> ToggleSettingActionPair<T> {
self
}
pub fn is_supported_on_current_platform(&self, value: bool) -> Self {
ToggleSettingActionPair {
descriptions: self.descriptions.clone(),
toggle_action: self.toggle_action.clone(),
contexts: self.contexts.clone(),
custom_action: self.custom_action,
binding_group: self.binding_group,
supported_on_current_platform: value,
enabled_predicate: None,
}
pub fn is_supported_on_current_platform(mut self, value: bool) -> Self {
self.supported_on_current_platform = value;
self
}
/// Creates enable/disable bindings for a toggle feature, given a list of `ToggleSettingActionPair`'s.
@@ -952,6 +1102,8 @@ macro_rules! update_page {
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update),
@@ -1034,11 +1186,18 @@ impl SettingsView {
me.handle_ai_page_event(event, ctx);
});
// Billing and usage page
let billing_and_usage_page_handle = ctx.add_typed_action_view(BillingAndUsagePageView::new);
ctx.subscribe_to_view(&billing_and_usage_page_handle, |me, _, event, ctx| {
// Environments page
let environments_page_handle = ctx.add_typed_action_view(EnvironmentsPageView::new);
ctx.subscribe_to_view(&environments_page_handle, |me, _, event, ctx| {
me.handle_environments_page_event(event, ctx);
});
// Billing & Usage page (internally, this routes to the v1 or v2 version. Depending on FFs and current plan).
let billing_and_usage_handle = ctx.add_view(BillingAndUsageDispatchView::new);
ctx.subscribe_to_view(&billing_and_usage_handle, |me, _, event, ctx| {
me.handle_billing_and_usage_page_event(event, ctx);
});
let billing_and_usage_page = SettingsPage::new(billing_and_usage_handle);
// Keybindings page
let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new);
@@ -1061,6 +1220,18 @@ impl SettingsView {
me.handle_privacy_page_event(event, ctx);
});
let referrals_client = ServerApiProvider::as_ref(ctx).get_referrals_client();
let referrals_page_handle =
ctx.add_typed_action_view(|ctx| ReferralsPageView::new(referrals_client, ctx));
ctx.subscribe_to_view(&referrals_page_handle, |me, _, event, ctx| {
me.handle_referrals_page_event(event, ctx);
});
let scripting_page_handle = if FeatureFlag::WarpControlCli.is_enabled() {
Some(ctx.add_typed_action_view(ScriptingSettingsPageView::new))
} else {
None
};
// Warp Drive page
let warp_drive_page_handle =
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
@@ -1110,7 +1281,7 @@ impl SettingsView {
let mut settings_pages = vec![
SettingsPage::new(main_page_handle),
SettingsPage::new(ai_page_handle),
SettingsPage::new(billing_and_usage_page_handle),
billing_and_usage_page,
SettingsPage::new(code_page_handle),
SettingsPage::new(appearance_page_handle),
SettingsPage::new(features_page_handle),
@@ -1120,6 +1291,10 @@ impl SettingsView {
SettingsPage::new(warp_drive_page_handle),
];
if let Some(scripting_page_handle) = scripting_page_handle {
settings_pages.push(SettingsPage::new(scripting_page_handle));
}
settings_pages.extend(vec![
SettingsPage::new(mcp_servers_page_handle),
SettingsPage::new(privacy_page_handle),
@@ -1149,10 +1324,26 @@ impl SettingsView {
SettingsNavItem::Page(SettingsSection::About),
];
if FeatureFlag::WarpControlCli.is_enabled() {
let shared_blocks_index = nav_items
.iter()
.position(|item| {
matches!(item, SettingsNavItem::Page(SettingsSection::SharedBlocks))
})
.unwrap_or(nav_items.len());
nav_items.insert(
shared_blocks_index,
SettingsNavItem::Page(SettingsSection::Scripting),
);
}
// Resolve the initial page: map internal backing-page sections to their default subpage.
let initial_page = match page {
Some(SettingsSection::AI) => SettingsSection::WarpAgent,
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
Some(SettingsSection::Scripting) if !FeatureFlag::WarpControlCli.is_enabled() => {
SettingsSection::Account
}
Some(section) if section.is_subpage() => section,
other => other.unwrap_or_default(),
};
@@ -1681,12 +1872,24 @@ impl SettingsView {
AISettingsPageEvent::OpenMCPServerCollection => {
ctx.emit(SettingsViewEvent::OpenMCPServerCollection)
}
#[cfg(feature = "local_fs")]
AISettingsPageEvent::OpenCustomRouterEditor(router) => {
ctx.emit(SettingsViewEvent::OpenCustomRouterEditor(router.clone()));
}
#[cfg(feature = "local_fs")]
AISettingsPageEvent::OpenCustomRouterFile(path) => {
ctx.emit(SettingsViewEvent::OpenCustomRouterFile(path.clone()));
}
AISettingsPageEvent::OpenExecutionProfileEditor(profile_id) => {
ctx.emit(SettingsViewEvent::OpenExecutionProfileEditor(*profile_id));
}
AISettingsPageEvent::SignupAnonymousUser => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
AISettingsPageEvent::ShowModal | AISettingsPageEvent::HideModal => {
// Modal rendering is handled in get_modal_content_for_page
ctx.notify();
}
}
}
@@ -1846,6 +2049,8 @@ impl SettingsView {
SettingsPageViewHandle::About(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app),
SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app),
@@ -2041,7 +2246,7 @@ impl SettingsView {
) -> Option<Box<dyn Element>> {
match page_handle {
SettingsPageViewHandle::BillingAndUsage(view) => {
view.read(app, |view, _| view.get_modal_content())
view.read(app, |view, _| view.get_modal_content(app))
}
SettingsPageViewHandle::Privacy(view) => {
view.read(app, |view, _| view.get_modal_content())
@@ -2049,6 +2254,9 @@ impl SettingsView {
SettingsPageViewHandle::MCPServers(view) => {
view.read(app, |view, _| view.get_modal_content(app))
}
SettingsPageViewHandle::AI(view) => {
view.read(app, |view, _| view.get_modal_content(app))
}
_ => None,
}
}
@@ -2513,5 +2721,5 @@ impl BackingView for SettingsView {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,6 +1,7 @@
use super::*;
use settings_page::MatchData;
use super::*;
// ── SettingsSection classification ──────────────────────────────────────────
#[test]
+7 -13
View File
@@ -1,18 +1,12 @@
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle};
use warpui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use super::settings_page::{MatchData, NAV_ITEM_LEFT_MARGIN};
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use galaxyui::{
elements::{Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle},
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
};
use pathfinder_geometry::vector::vec2f;
use super::{
settings_page::{MatchData, NAV_ITEM_LEFT_MARGIN},
SettingsSection,
};
/// The font size for subpage items inside an umbrella.
const SUBPAGE_FONT_SIZE: f32 = 10.;
+4 -6
View File
@@ -1,12 +1,10 @@
use crate::pane_group::SettingsPane;
use crate::{
pane_group::{PaneContent, PaneId},
PaneViewLocator,
};
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
use std::collections::HashMap;
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
use super::SettingsView;
use crate::pane_group::{PaneContent, PaneId, SettingsPane};
use crate::PaneViewLocator;
struct SettingsPaneData {
locator: Option<PaneViewLocator>,
settings_view: ViewHandle<SettingsView>,
@@ -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(
+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;
@@ -0,0 +1,118 @@
use chrono::Utc;
use super::{
api_key_table_min_non_resizable_columns_width, compute_api_key_name_column_max_width,
APIKeyProperties, ApiKeyScope, API_KEY_KEY_COLUMN_WIDTH, API_KEY_NAME_COLUMN_MIN_WIDTH,
API_KEY_TABLE_LAYOUT_SAFETY_PADDING, API_KEY_TABLE_MIN_SCOPE_COLUMN_WIDTH,
SETTINGS_PAGE_HORIZONTAL_PADDING, SETTINGS_PAGE_MAX_CONTENT_WIDTH,
SETTINGS_SECTION_BORDER_WIDTH, SETTINGS_SIDEBAR_WIDTH_DEFAULT,
};
fn table_width_chrome() -> f32 {
SETTINGS_SIDEBAR_WIDTH_DEFAULT
+ SETTINGS_SECTION_BORDER_WIDTH
+ SETTINGS_PAGE_HORIZONTAL_PADDING
+ API_KEY_TABLE_LAYOUT_SAFETY_PADDING
}
fn assert_f32_eq(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < f32::EPSILON,
"expected {expected}, got {actual}"
);
}
#[test]
fn key_column_width_is_fixed_and_narrow() {
assert_f32_eq(API_KEY_KEY_COLUMN_WIDTH, 120.);
}
#[test]
fn name_column_max_width_reserves_non_resizable_columns_without_scope() {
let min_non_resizable_columns_width = api_key_table_min_non_resizable_columns_width(false);
let max_width = compute_api_key_name_column_max_width(
2000.,
API_KEY_NAME_COLUMN_MIN_WIDTH,
min_non_resizable_columns_width,
table_width_chrome(),
);
let expected = SETTINGS_PAGE_MAX_CONTENT_WIDTH - min_non_resizable_columns_width;
assert_f32_eq(max_width, expected);
}
#[test]
fn name_column_max_width_reserves_extra_scope_budget_when_scope_enabled() {
let min_without_scope = api_key_table_min_non_resizable_columns_width(false);
let min_with_scope = api_key_table_min_non_resizable_columns_width(true);
assert_f32_eq(
min_with_scope - min_without_scope,
API_KEY_TABLE_MIN_SCOPE_COLUMN_WIDTH,
);
let max_without_scope = compute_api_key_name_column_max_width(
2000.,
API_KEY_NAME_COLUMN_MIN_WIDTH,
min_without_scope,
table_width_chrome(),
);
let max_with_scope = compute_api_key_name_column_max_width(
2000.,
API_KEY_NAME_COLUMN_MIN_WIDTH,
min_with_scope,
table_width_chrome(),
);
assert_f32_eq(
max_without_scope - max_with_scope,
API_KEY_TABLE_MIN_SCOPE_COLUMN_WIDTH,
);
}
#[test]
fn name_column_max_width_never_drops_below_min_width() {
let max_width = compute_api_key_name_column_max_width(
200.,
API_KEY_NAME_COLUMN_MIN_WIDTH,
api_key_table_min_non_resizable_columns_width(false),
table_width_chrome(),
);
assert_f32_eq(max_width, API_KEY_NAME_COLUMN_MIN_WIDTH);
}
fn test_api_key(name: &str, agent_name: Option<&str>) -> APIKeyProperties {
APIKeyProperties {
uid: "api-key-uid".to_string(),
name: name.to_string(),
key_suffix: "abcd".to_string(),
scope: ApiKeyScope::Personal,
agent_name: agent_name.map(str::to_string),
created_at: Utc::now(),
last_used_at: None,
expires_at: None,
}
}
#[test]
fn api_key_search_matches_key_name_case_insensitively() {
let key = test_api_key("Production Deploy Key", None);
assert!(key.matches_search_query("deploy", false));
assert!(key.matches_search_query("PRODUCTION", false));
assert!(!key.matches_search_query("staging", false));
}
#[test]
fn api_key_search_matches_agent_name_only_when_enabled() {
let key = test_api_key("Production Key", Some("Release Manager"));
assert!(key.matches_search_query("release", true));
assert!(!key.matches_search_query("release", false));
}
#[test]
fn api_key_search_treats_empty_query_as_match() {
let key = test_api_key("Production Key", Some("Release Manager"));
assert!(key.matches_search_query("", false));
assert!(key.matches_search_query(" ", true));
}
@@ -1,20 +1,21 @@
use crate::editor::Event as EditorEvent;
use crate::modal::{Modal, ModalViewState};
use crate::{
appearance::Appearance,
editor::{EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions},
};
use regex::Regex;
use galaxy_editor::editor::NavigationKey;
use galaxyui::elements::{CrossAxisAlignment, Expanded, MainAxisSize};
use galaxyui::elements::{
ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize,
MouseStateHandle, ParentElement, Text,
};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{ChildView, Container, Empty, Flex, MouseStateHandle, ParentElement, Text},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use regex::Regex;
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::modal::{Modal, ModalViewState};
const LABEL_FONT_SIZE: f32 = 12.;
+45 -67
View File
@@ -5,69 +5,59 @@ use std::sync::LazyLock;
use std::time::Duration;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxy_core::context_flag::ContextFlag;
use regex::Regex;
use settings::Setting as _;
use galaxy_core::ui::theme::GalaxyTheme;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use galaxyui::elements::{
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Shrinkable,
Stack, Text,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::ContextPredicate;
use galaxyui::platform::Cursor;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::{SwitchStateHandle, TooltipConfig},
};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::{SwitchStateHandle, TooltipConfig};
use galaxyui::{
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView,
id, Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView,
UpdateModel, View, ViewContext, ViewHandle,
};
use crate::settings::{CustomSecretRegex, RegexDisplayInfo};
use super::privacy::{AddRegexModal, AddRegexModalEvent};
use super::settings_page::{
render_body_item, render_sub_header, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget, ToggleState, HEADER_PADDING, PAGE_PADDING,
TOGGLE_BUTTON_RIGHT_PADDING,
};
use super::{flags, SettingsAction, SettingsSection, ToggleSettingActionPair};
use crate::appearance::Appearance;
use crate::auth::auth_manager::AuthManager;
use crate::channel::ChannelState;
use crate::modal::{Modal, ModalEvent, ModalViewState};
use crate::server::telemetry::TelemetryEvent;
use crate::settings::{AISettings, CustomSecretRegex, PrivacySettings, RegexDisplayInfo};
use crate::settings_view::privacy::AddRegexModalViewState;
use crate::settings_view::render_body_item_label;
use crate::settings_view::settings_page::CONTENT_FONT_SIZE;
use crate::terminal::safe_mode_settings::{
get_effective_secret_display_mode, SecretDisplayMode, SecretDisplayModeSetting,
get_effective_secret_display_mode, SafeModeEnabled, SafeModeSettings, SecretDisplayMode,
SecretDisplayModeSetting,
};
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::util::links::PRIVACY_POLICY_URL;
use crate::view_components::{Dropdown, DropdownItem};
use crate::{
appearance::Appearance,
channel::ChannelState,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings::PrivacySettings,
terminal::safe_mode_settings::{SafeModeEnabled, SafeModeSettings},
ui_components::icons::Icon,
workspaces::{
user_workspaces::UserWorkspaces,
workspace::{CustomerType, UgcCollectionEnablementSetting},
},
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::{
AdminEnablementSetting, CustomerType, UgcCollectionEnablementSetting,
};
use super::{
flags,
privacy::{AddRegexModal, AddRegexModalEvent},
settings_page::{
render_body_item, render_sub_header, SettingsPageMeta, SettingsPageViewHandle, ToggleState,
HEADER_PADDING, TOGGLE_BUTTON_RIGHT_PADDING,
},
settings_page::{LocalOnlyIconState, MatchData, PageType, SettingsWidget, PAGE_PADDING},
SettingsAction, SettingsSection, ToggleSettingActionPair,
};
use crate::modal::{Modal, ModalEvent, ModalViewState};
use galaxyui::fonts::Weight;
use crate::{report_if_error, send_telemetry_from_ctx};
const FONT_SIZE: f32 = 12.;
@@ -89,9 +79,7 @@ const TELEMETRY_DESCRIPTION_OLD: &str =
const TELEMETRY_TITLE: &str = "Help improve Galaxy";
const TELEMETRY_DESCRIPTION: &str =
"App analytics help us make the product better for you. We may collect \
certain console interactions to improve Galaxy's AI capabilities.";
const TELEMETRY_FREE_TIER_NOTE: &str =
"On the free tier, analytics must be enabled to use AI features.";
certain console interactions to improve Warp's AI capabilities.";
const TELEMETRY_DOCS_URL: &str =
"https://docs.warp.dev/support-and-community/privacy-and-security/privacy#what-telemetry-data-does-warp-collect-and-why";
@@ -1482,13 +1470,6 @@ impl SettingsWidget for AppAnalyticsWidget {
.finish()
};
// Check if user is on free tier to show the AI requirement note
// Fail safe: if billing status is unknown, assume paid (don't show free tier note)
let is_on_paid_plan = UserWorkspaces::as_ref(app)
.current_workspace()
.map(|w| w.billing_metadata.is_user_on_paid_plan())
.unwrap_or(true);
let mut column = Flex::column();
column.add_child(super::settings_page::build_toggle_element(
zdr_label_component,
@@ -1512,23 +1493,6 @@ impl SettingsWidget for AppAnalyticsWidget {
.finish(),
);
// Show free tier note only for non-paid users
if !is_on_paid_plan {
column.add_child(
ui_builder
.paragraph(TELEMETRY_FREE_TIER_NOTE)
.with_style(UiComponentStyles {
font_color: Some(description_text_color),
margin: Some(
Coords::default().bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM),
),
..Default::default()
})
.build()
.finish(),
);
}
column.add_child(
Align::new(
ui_builder
@@ -1736,6 +1700,20 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
flags::SAFE_MODE_FLAG,
));
toggle_binding_pairs.push(
ToggleSettingActionPair::new(
"cloud AI conversation storage",
builder(SettingsAction::PrivacyPageToggle(
PrivacyPageAction::ToggleCloudConversationStorage,
)),
&(context.clone()
& id!(flags::IS_ANY_AI_ENABLED)
& id!(flags::CLOUD_CONVERSATION_STORAGE_EDITABLE_FLAG)),
flags::CLOUD_CONVERSATION_STORAGE_FLAG,
)
.with_enabled(|| FeatureFlag::CloudConversations.is_enabled()),
);
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(toggle_binding_pairs, app);
}
+24 -30
View File
@@ -1,45 +1,39 @@
use std::ops::Deref;
use std::sync::Arc;
use lazy_static::lazy_static;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use std::{ops::Deref, sync::Arc};
use thiserror::Error;
use validator::ValidateEmail;
use super::{
settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, PAGE_PADDING,
},
SettingsSection,
};
use crate::{
appearance::Appearance,
auth::AuthStateProvider,
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
safe_info, send_telemetry_from_ctx,
server::{
server_api::referral::{ReferralInfo, ReferralsClient},
telemetry::TelemetryEvent,
},
ui_components::blended_colors,
view_components::ToastFlavor,
use warpui::clipboard::ClipboardContent;
use warpui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Fill,
Flex, FormattedTextElement, HighlightedHyperlink, Icon, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Rect, Shrinkable,
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
clipboard::ClipboardContent,
elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Fill,
Flex, FormattedTextElement, HighlightedHyperlink, Icon, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Rect, Shrinkable,
},
fonts::Weight,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Entity, EventContext, FocusContext, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use super::settings_page::{
MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, PAGE_PADDING,
};
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use crate::server::server_api::referral::{ReferralInfo, ReferralsClient};
use crate::server::telemetry::TelemetryEvent;
use crate::ui_components::blended_colors;
use crate::view_components::ToastFlavor;
use crate::{safe_info, send_telemetry_from_ctx};
const HEADER_FONT_SIZE: f32 = 18.;
const HEADER_MARGIN_BOTTOM: f32 = 32.;
const HEADER_TEXT: &str = "Invite a friend to Warp";
@@ -0,0 +1,172 @@
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));
}
}
}
}
}
+302
View File
@@ -0,0 +1,302 @@
//! Settings UI for local scripting and Warp control permissions.
use std::cell::RefCell;
use std::collections::HashMap;
use settings::Setting as _;
#[cfg(target_os = "macos")]
use galaxy_core::channel::ChannelState;
use warpui::elements::{ChildView, Element, MouseStateHandle};
#[cfg(target_os = "macos")]
use warpui::ui_components::button::ButtonVariant;
#[cfg(target_os = "macos")]
use warpui::ui_components::components::UiComponent;
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use super::settings_page::{
render_body_item, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
};
use super::{SettingsSection, ToggleState};
use crate::appearance::Appearance;
use crate::features::FeatureFlag;
use crate::report_if_error;
use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings};
#[cfg(target_os = "macos")]
use crate::view_components::DismissibleToast;
use crate::view_components::{Dropdown, DropdownItem};
#[cfg(target_os = "macos")]
use crate::workspace::{cli_install, ToastStack};
#[derive(Clone, Debug, PartialEq)]
pub enum ScriptingSettingsPageAction {
SetLocalControlMode(LocalControlMode),
#[cfg(target_os = "macos")]
InstallWarpControlCli,
}
pub struct ScriptingSettingsPageView {
page: PageType<Self>,
local_only_icon_tooltip_states: RefCell<HashMap<String, MouseStateHandle>>,
local_control_mode_dropdown: ViewHandle<Dropdown<ScriptingSettingsPageAction>>,
#[cfg(target_os = "macos")]
warpctrl_installing: bool,
}
impl ScriptingSettingsPageView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let local_control_mode_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(360.);
dropdown
});
Self::update_local_control_mode_dropdown(local_control_mode_dropdown.clone(), ctx);
if FeatureFlag::WarpControlCli.is_enabled() {
ctx.subscribe_to_model(&LocalControlSettings::handle(ctx), |view, _, _, ctx| {
Self::update_local_control_mode_dropdown(
view.local_control_mode_dropdown.clone(),
ctx,
);
ctx.notify();
});
}
#[cfg(target_os = "macos")]
let widgets: Vec<Box<dyn SettingsWidget<View = Self>>> = vec![
Box::new(WarpControlCliInstallWidget::default()),
Box::new(LocalControlModeWidget),
];
#[cfg(not(target_os = "macos"))]
let widgets: Vec<Box<dyn SettingsWidget<View = Self>>> =
vec![Box::new(LocalControlModeWidget)];
Self {
page: PageType::new_uncategorized(widgets, Some("Scripting")),
local_only_icon_tooltip_states: RefCell::new(HashMap::new()),
local_control_mode_dropdown,
#[cfg(target_os = "macos")]
warpctrl_installing: false,
}
}
fn update_local_control_mode_dropdown(
dropdown: ViewHandle<Dropdown<ScriptingSettingsPageAction>>,
ctx: &mut ViewContext<Self>,
) {
let current_mode = LocalControlSettings::as_ref(ctx).mode();
dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(
LocalControlMode::ALL
.into_iter()
.map(|mode| {
DropdownItem::new(
mode.as_dropdown_label(),
ScriptingSettingsPageAction::SetLocalControlMode(mode),
)
})
.collect(),
ctx,
);
dropdown.set_selected_by_action(
ScriptingSettingsPageAction::SetLocalControlMode(current_mode),
ctx,
);
});
}
#[cfg(target_os = "macos")]
fn install_warpctrl(&mut self, ctx: &mut ViewContext<Self>) {
if self.warpctrl_installing || cli_install::is_warpctrl_installed() {
return;
}
self.warpctrl_installing = true;
ctx.notify();
let window_id = ctx.window_id();
ctx.spawn(
async { cli_install::install_warpctrl() },
move |view, result, ctx| {
view.warpctrl_installing = false;
match result {
Ok(()) => {
let command_name = ChannelState::channel().warpctrl_command_name();
let message = format!(
"Successfully installed the Warp Control CLI! You can now run '{command_name}' from the command line."
);
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success(message),
window_id,
ctx,
);
});
}
Err(error) => {
let message = format!("Failed to install Warp Control command: {error}");
log::warn!("{message}");
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(
DismissibleToast::error(message),
window_id,
ctx,
);
});
}
}
ctx.notify();
},
);
}
}
impl Entity for ScriptingSettingsPageView {
type Event = ();
}
impl TypedActionView for ScriptingSettingsPageView {
type Action = ScriptingSettingsPageAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ScriptingSettingsPageAction::SetLocalControlMode(mode) => {
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.local_control_mode.set_value(*mode, ctx));
});
ctx.notify();
}
#[cfg(target_os = "macos")]
ScriptingSettingsPageAction::InstallWarpControlCli => self.install_warpctrl(ctx),
}
}
}
impl View for ScriptingSettingsPageView {
fn ui_name() -> &'static str {
"ScriptingSettingsPage"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for ScriptingSettingsPageView {
fn section() -> SettingsSection {
SettingsSection::Scripting
}
fn should_render(&self, _ctx: &AppContext) -> bool {
cfg!(not(target_family = "wasm")) && FeatureFlag::WarpControlCli.is_enabled()
}
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<ScriptingSettingsPageView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<ScriptingSettingsPageView>) -> Self {
SettingsPageViewHandle::Scripting(view_handle)
}
}
#[cfg(target_os = "macos")]
#[derive(Default)]
struct WarpControlCliInstallWidget {
install_button_mouse_state: MouseStateHandle,
}
#[cfg(target_os = "macos")]
impl SettingsWidget for WarpControlCliInstallWidget {
type View = ScriptingSettingsPageView;
fn search_terms(&self) -> &str {
"warp control cli command warpctrl install scripting"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let installed = cli_install::is_warpctrl_installed();
let disabled = view.warpctrl_installing || installed;
let label = if view.warpctrl_installing {
"Installing…"
} else if installed {
"Installed"
} else {
"Install"
};
let mut button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.install_button_mouse_state.clone(),
)
.with_text_label(label.to_owned());
if disabled {
button = button.disabled();
}
let button = if disabled {
button.build().finish()
} else {
button
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ScriptingSettingsPageAction::InstallWarpControlCli);
})
.finish()
};
render_body_item::<ScriptingSettingsPageAction>(
"Warp Control CLI command".into(),
None,
LocalOnlyIconState::Hidden,
ToggleState::Enabled,
appearance,
button,
Some("Install the warpctrl command for scripting Warp from your terminal.".to_owned()),
)
}
}
struct LocalControlModeWidget;
impl SettingsWidget for LocalControlModeWidget {
type View = ScriptingSettingsPageView;
fn search_terms(&self) -> &str {
"scripting warp control automation warpctrl local cli scripts disabled enabled"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
render_body_item::<ScriptingSettingsPageAction>(
"warpctrl CLI".into(),
None,
LocalOnlyIconState::for_setting(
LocalControlModeSetting::storage_key(),
LocalControlModeSetting::sync_to_cloud(),
&mut view.local_only_icon_tooltip_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
ChildView::new(&view.local_control_mode_dropdown).finish(),
Some("warpctrl allows for scripting Warp's UI. Use with care.".to_owned()),
)
}
}
@@ -0,0 +1,213 @@
use warpui::elements::{
ChildView, Container, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex,
MainAxisAlignment, MainAxisSize, ParentElement, Text,
};
use warpui::{
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
WeakViewHandle,
};
use crate::ai::llms::LLMId;
use crate::appearance::Appearance;
use crate::view_components::action_button::{ActionButton, NakedTheme, PrimaryTheme};
use crate::view_components::{DropdownItem, FilterableDropdown, FilterableDropdownEvent};
/// Width shared by the model dropdown's top bar and open menu so long model
/// names stay readable inside the modal.
const MODEL_DROPDOWN_WIDTH: f32 = 400.;
/// The body's `ui_font_size`-based default reads too small in the modal, so the
/// description uses an explicit, slightly larger size.
const DESCRIPTION_FONT_SIZE: f32 = 14.;
pub enum SetDefaultModelModalBodyEvent {
/// The user dismissed the prompt without choosing a model.
Close,
/// The user committed `LLMId` as their new default Agent Mode model.
SetDefault(LLMId),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SetDefaultModelModalBodyAction {
/// Carries the index into `model_choices` of the picked model.
SelectModel(usize),
Save,
Cancel,
}
/// Body of the "change your default model" prompt that appears after a BYO API
/// key or custom endpoint is saved. It is hosted inside a [`crate::modal::Modal`],
/// which supplies the title, close button, and backdrop.
pub struct SetDefaultModelModalBody {
description: String,
/// `(model id, label)` pairs offered in the dropdown. The id flows back out
/// through [`SetDefaultModelModalBodyEvent::SetDefault`] on save.
model_choices: Vec<(LLMId, String)>,
selected_index: usize,
model_dropdown: ViewHandle<FilterableDropdown<SetDefaultModelModalBodyAction>>,
cancel_button: ViewHandle<ActionButton>,
save_button: ViewHandle<ActionButton>,
self_handle: WeakViewHandle<Self>,
}
impl SetDefaultModelModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let model_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = FilterableDropdown::new(ctx);
dropdown.set_top_bar_max_width(MODEL_DROPDOWN_WIDTH);
dropdown.set_menu_width(MODEL_DROPDOWN_WIDTH, ctx);
dropdown
});
// When the dropdown closes (selection or dismiss), return focus to the
// body so Escape closes the modal rather than no-op'ing on the hidden
// filter input.
ctx.subscribe_to_view(&model_dropdown, |_, _, event, ctx| {
if let FilterableDropdownEvent::Close = event {
ctx.focus_self();
}
});
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Not now", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
})
});
let save_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Change default model", PrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Save);
})
});
Self {
description: String::new(),
model_choices: Vec::new(),
selected_index: 0,
model_dropdown,
cancel_button,
save_button,
self_handle: ctx.handle(),
}
}
/// Populates the prompt for a freshly added credential and focuses the body
/// so Escape closes the modal. The first model is pre-selected so the user
/// can accept without opening the dropdown.
pub fn set_choices(
&mut self,
description: String,
model_choices: Vec<(LLMId, String)>,
ctx: &mut ViewContext<Self>,
) {
self.description = description;
self.model_choices = model_choices;
self.selected_index = 0;
let items = self
.model_choices
.iter()
.enumerate()
.map(|(index, (_, label))| {
DropdownItem::new(
label.clone(),
SetDefaultModelModalBodyAction::SelectModel(index),
)
})
.collect();
self.model_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
});
ctx.focus_self();
ctx.notify();
}
}
impl Entity for SetDefaultModelModalBody {
type Event = SetDefaultModelModalBodyEvent;
}
impl View for SetDefaultModelModalBody {
fn ui_name() -> &'static str {
"SetDefaultModelModalBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let description = Container::new(
Text::new(
self.description.clone(),
appearance.ui_font_family(),
DESCRIPTION_FONT_SIZE,
)
.with_color(theme.nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.with_margin_bottom(20.)
.finish();
let dropdown = Container::new(ChildView::new(&self.model_dropdown).finish())
.with_margin_bottom(24.)
.finish();
let buttons_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&self.cancel_button).finish())
.with_child(
Container::new(ChildView::new(&self.save_button).finish())
.with_margin_left(12.)
.finish(),
)
.finish();
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(description)
.with_child(dropdown)
.with_child(buttons_row)
.finish();
// Close the modal on Escape when the body itself is focused. While the
// dropdown is open it owns Escape (to close itself); on close it hands
// focus back to the body via the `Close` subscription above.
let self_handle = self.self_handle.clone();
EventHandler::new(content)
.on_keydown(move |ctx, app, keystroke| {
let body_focused = self_handle
.upgrade(app)
.is_some_and(|handle| handle.is_focused(app));
if body_focused && keystroke.is_unmodified_key("escape") {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
DispatchEventResult::StopPropagation
} else {
DispatchEventResult::PropagateToParent
}
})
.finish()
}
}
impl TypedActionView for SetDefaultModelModalBody {
type Action = SetDefaultModelModalBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SetDefaultModelModalBodyAction::SelectModel(index) => {
self.selected_index = *index;
ctx.notify();
}
SetDefaultModelModalBodyAction::Save => {
if let Some((id, _)) = self.model_choices.get(self.selected_index) {
ctx.emit(SetDefaultModelModalBodyEvent::SetDefault(id.clone()));
}
}
SetDefaultModelModalBodyAction::Cancel => {
ctx.emit(SetDefaultModelModalBodyEvent::Close);
}
}
}
}
@@ -7,10 +7,7 @@
//! `Workspace::render_settings_error_banner`) when the settings file has an
//! error *and* the user has dismissed the workspace banner.
//! * Otherwise, a plain bordered "Open settings file" button.
use crate::appearance::Appearance;
use crate::settings::SettingsFileError;
use crate::ui_components::icons::Icon;
use crate::WorkspaceAction;
use pathfinder_color::ColorU;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
@@ -21,7 +18,11 @@ use galaxyui::elements::{
};
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::platform::Cursor;
use pathfinder_color::ColorU;
use crate::appearance::Appearance;
use crate::settings::SettingsFileError;
use crate::ui_components::icons::Icon;
use crate::WorkspaceAction;
/// Horizontal + vertical padding applied to the footer inside the sidebar.
const FOOTER_PADDING: f32 = 12.;
+152 -84
View File
@@ -1,57 +1,55 @@
use crate::ui_components::blended_colors;
use core::fmt::{self, Display};
use itertools::Itertools as _;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use std::collections::HashMap;
use super::{
about_page::AboutPageView,
ai_page::{AISettingsPageAction, AISettingsPageView},
appearance_page::AppearanceSettingsPageView,
billing_and_usage_page::BillingAndUsagePageView,
code_page::CodeSettingsPageView,
environments_page::EnvironmentsPageView,
features_page::FeaturesPageView,
keybindings::KeybindingsView,
main_page::MainSettingsPageView,
mcp_servers_page::MCPServersSettingsPageView,
privacy_page::PrivacyPageView,
show_blocks_view::ShowBlocksView,
warp_drive_page::WarpDriveSettingsPageView,
warpify_page::WarpifyPageView,
SettingsSection,
};
use crate::{
appearance::Appearance,
settings::CloudPreferencesSettings,
themes::theme::Fill,
ui_components::icons::Icon,
view_components::{Dropdown, SubmittableTextInput},
};
use galaxy_core::{
settings::SyncToCloud,
ui::{color::blend::Blend, theme::color::internal_colors},
};
use galaxyui::{
elements::{
new_scrollable::{ClippedAxisConfiguration, DualAxisConfig, SingleAxisConfig},
Align, Border, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Element, Empty, Expanded, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable, ParentElement, Radius,
SavePosition, ScrollTarget, ScrollToPositionMode, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
ui_components::{
button::{Button, ButtonVariant},
components::{Coords, UiComponent, UiComponentStyles},
},
units::Pixels,
Action, AppContext, SingletonEntity, ViewContext, ViewHandle,
};
use itertools::Itertools as _;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use galaxy_core::settings::SyncToCloud;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::new_scrollable::{
ClippedAxisConfiguration, DualAxisConfig, SingleAxisConfig,
};
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Element, Empty, Expanded, Flex, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::{Button, ButtonVariant};
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::units::Pixels;
use galaxyui::{Action, AppContext, SingletonEntity, ViewContext, ViewHandle};
use super::about_page::AboutPageView;
use super::ai_page::{AISettingsPageAction, AISettingsPageView};
use super::appearance_page::AppearanceSettingsPageView;
use super::billing_and_usage_dispatch::BillingAndUsageDispatchView;
use super::code_page::CodeSettingsPageView;
use super::environments_page::EnvironmentsPageView;
use super::features_page::FeaturesPageView;
use super::keybindings::KeybindingsView;
use super::main_page::MainSettingsPageView;
use super::mcp_servers_page::MCPServersSettingsPageView;
use super::privacy_page::PrivacyPageView;
use super::referrals_page::ReferralsPageView;
use super::scripting_page::ScriptingSettingsPageView;
use super::show_blocks_view::ShowBlocksView;
use super::teams_page::TeamsPageView;
use super::warp_drive_page::WarpDriveSettingsPageView;
use super::warpify_page::WarpifyPageView;
use super::SettingsSection;
use crate::appearance::Appearance;
use crate::settings::CloudPreferencesSettings;
use crate::themes::theme::Fill;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::{Dropdown, DropdownItemAction, SubmittableTextInput};
pub const TOGGLE_BUTTON_RIGHT_PADDING: f32 = 5.;
pub const HEADER_PADDING: f32 = 15.;
@@ -66,6 +64,7 @@ const ALTERNATING_LIST_ITEM_PADDING: f32 = 8.0;
const GREY_TEXT_OPACITY: u8 = 60;
const MIN_PAGE_WIDTH: f32 = 520.;
const MAX_PAGE_WIDTH: f32 = 800.;
const INFO_TOOLTIP_MAX_WIDTH: f32 = 320.;
/// Left margin for top-level sidebar nav items (pages and umbrella labels).
pub(super) const NAV_ITEM_LEFT_MARGIN: f32 = 12.;
@@ -109,8 +108,11 @@ pub enum SettingsPageViewHandle {
Code(ViewHandle<CodeSettingsPageView>),
Privacy(ViewHandle<PrivacyPageView>),
Warpify(ViewHandle<WarpifyPageView>),
Referrals(ViewHandle<ReferralsPageView>),
Scripting(ViewHandle<ScriptingSettingsPageView>),
AI(ViewHandle<AISettingsPageView>),
BillingAndUsage(ViewHandle<BillingAndUsagePageView>),
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
BillingAndUsage(ViewHandle<BillingAndUsageDispatchView>),
MCPServers(ViewHandle<MCPServersSettingsPageView>),
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
@@ -131,6 +133,8 @@ impl SettingsPageViewHandle {
Code(view_handle) => ChildView::new(view_handle).finish(),
Privacy(view_handle) => ChildView::new(view_handle).finish(),
Warpify(view_handle) => ChildView::new(view_handle).finish(),
Referrals(view_handle) => ChildView::new(view_handle).finish(),
Scripting(view_handle) => ChildView::new(view_handle).finish(),
AI(view_handle) => ChildView::new(view_handle).finish(),
BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(),
MCPServers(view_handle) => ChildView::new(view_handle).finish(),
@@ -545,26 +549,54 @@ pub fn render_info_icon<T: Clone + Action>(
appearance: &Appearance,
additional_info: AdditionalInfo<T>,
) -> Box<dyn Element> {
let info_button = appearance
.ui_builder()
.info_button_with_tooltip(
13.,
additional_info
.tooltip_override_text
.unwrap_or("Click to learn more in docs".to_owned()),
additional_info.mouse_state.clone(),
let tooltip_text = additional_info
.tooltip_override_text
.unwrap_or("Click to learn more in docs".to_owned());
let icon = Container::new(
ConstrainedBox::new(
Icon::Info
.to_warpui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.on_click(move |ctx, _, _| {
if let Some(on_click_action) = &additional_info.on_click_action {
ctx.dispatch_typed_action(on_click_action.clone());
}
})
.finish();
.with_width(13.)
.with_height(13.)
.finish(),
)
.finish();
Container::new(info_button)
let mut info_button = Hoverable::new(additional_info.mouse_state.clone(), move |state| {
let mut stack = Stack::new().with_child(icon);
if state.is_hovered() {
let tool_tip = ConstrainedBox::new(
appearance
.ui_builder()
.tool_tip(tooltip_text)
.build()
.finish(),
)
.with_max_width(INFO_TOOLTIP_MAX_WIDTH)
.finish();
stack.add_positioned_child(
tool_tip,
OffsetPositioning::offset_from_parent(
vec2f(0., -3.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.with_cursor(Cursor::PointingHand);
if let Some(on_click_action) = additional_info.on_click_action {
info_button = info_button
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(on_click_action.clone()));
}
Container::new(Box::new(info_button))
.with_margin_left(4.)
// Since the icon is smaller than the font, we need some margin to be in alignment.
.with_margin_top(1.5)
.finish()
}
@@ -582,11 +614,7 @@ pub fn render_local_only_icon(
)
.finish();
Container::new(info_button)
.with_margin_left(4.)
// Since the icon is smaller than the font, we need some margin to be in alignment.
.with_margin_top(1.5)
.finish()
Container::new(info_button).with_margin_left(4.).finish()
}
pub fn render_body_item_label<T: Clone + Action>(
@@ -890,7 +918,7 @@ pub fn render_dropdown_item_label(
}
}
pub(crate) fn render_dropdown_item<T: Clone + Action>(
pub(crate) fn render_dropdown_item<T: DropdownItemAction>(
appearance: &Appearance,
label: &str,
secondary_text: Option<&str>,
@@ -996,12 +1024,17 @@ pub(crate) fn render_settings_info_banner(
.finish()
}
const WORKSPACE_OVERRIDE_TOOLTIP_TEXT: &str =
"This option is enforced by your organization's settings and cannot be customized.";
pub struct InputListItem<SettingsPageAction: Action + Clone> {
pub item: String,
pub mouse_state_handle: MouseStateHandle,
pub on_remove_action: SettingsPageAction,
pub is_disabled: bool,
/// Must be pre-created (not inline during render) to preserve mouse tracking.
pub tooltip_mouse_state: Option<MouseStateHandle>,
}
/// Renders a title, an input field to add new items and a list of already
/// added items.
///
@@ -1010,7 +1043,6 @@ pub fn render_input_list<SettingsPageAction: Action + Clone>(
title: Option<&str>,
items: impl IntoIterator<Item = InputListItem<SettingsPageAction>>,
handle: Option<&ViewHandle<SubmittableTextInput>>,
disabled: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut column = Flex::column();
@@ -1036,15 +1068,21 @@ pub fn render_input_list<SettingsPageAction: Action + Clone>(
let background = appearance.theme().surface_1();
let peekable = items.into_iter().peekable();
for item in peekable {
let mut container = Container::new(render_alternating_color_list_item(
let disabled = item.is_disabled;
let row_element = render_alternating_color_list_item(
background,
item.item,
item.mouse_state_handle,
item.on_remove_action,
disabled,
appearance,
));
container = container.with_margin_bottom(4.);
);
let row_element = if let Some(tooltip_mouse_state) = item.tooltip_mouse_state {
render_workspace_override_row_tooltip(row_element, tooltip_mouse_state, appearance)
} else {
row_element
};
let container = Container::new(row_element).with_margin_bottom(4.);
column.add_child(container.finish());
}
@@ -1086,6 +1124,34 @@ pub fn render_alternating_color_list<
}
}
fn render_workspace_override_row_tooltip(
child: Box<dyn Element>,
mouse_state: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
Hoverable::new(mouse_state, |state| {
let mut stack = Stack::new().with_child(child);
if state.is_hovered() {
let tooltip = appearance
.ui_builder()
.tool_tip(WORKSPACE_OVERRIDE_TOOLTIP_TEXT.to_string())
.build()
.finish();
stack.add_positioned_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., -4.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
);
}
stack.finish()
})
.finish()
}
fn render_alternating_color_list_item<SettingsPageAction: Action + Clone>(
background: impl Into<Fill>,
item_label: String,
@@ -1102,10 +1168,12 @@ fn render_alternating_color_list_item<SettingsPageAction: Action + Clone>(
remove_button = remove_button.disabled();
}
let remove_button = remove_button
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish();
let mut remove_button = remove_button.build();
if !disabled {
remove_button =
remove_button.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()));
}
let remove_button = remove_button.finish();
let background = background.into();
let font_color = if disabled {
@@ -1147,7 +1215,7 @@ fn render_alternating_color_list_item<SettingsPageAction: Action + Clone>(
.with_uniform_padding(ALTERNATING_LIST_ITEM_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
// The bottom has a bit of extra padding b/c lines of text have more space above the text
// than below. This visually balances that to make it lok vertically centered.
// than below. This visually balances that to make it look vertically centered.
.with_padding_bottom(ALTERNATING_LIST_ITEM_PADDING + 2.)
.finish()
}
@@ -1535,7 +1603,7 @@ impl<V: galaxyui::View> PageType<V> {
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
pub fn scroll_by(&self, delta: Pixels) {
match self {
PageType::Monolith {
+26 -30
View File
@@ -1,41 +1,37 @@
#![allow(dead_code, unused_imports, unused_variables)]
use super::{
settings_page::{
render_page_title, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, HEADER_FONT_SIZE, PAGE_PADDING,
},
SettingsSection,
};
use crate::auth::AuthStateProvider;
use crate::{
appearance::Appearance,
channel::{Channel, ChannelState},
menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields},
server::{block::Block, server_api::block::BlockClient},
view_components::ToastFlavor,
};
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, FixedOffset, Local};
use pathfinder_geometry::vector::vec2f;
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::{
clipboard::ClipboardContent,
elements::{
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Expanded, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, SavePosition, ScrollStateHandle, Scrollable,
ScrollableElement, Shrinkable, Stack, UniformList, UniformListState,
},
};
use galaxyui::{color::ColorU, elements::Radius};
use galaxyui::{elements::ScrollbarWidth, fonts::Weight};
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use std::sync::Arc;
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;
+4 -4
View File
@@ -1,14 +1,14 @@
use galaxyui::ui_components::button::ButtonVariant;
use std::fmt::Display;
use galaxyui::elements::MouseStateHandle;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::Element;
use super::teams_page::TeamsPageAction;
use crate::cloud_object::model::persistence::CloudModel;
use crate::workspaces::team::Team;
use crate::Appearance;
use galaxyui::elements::MouseStateHandle;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::ui_components::components::UiComponentStyles;
use galaxyui::Element;
/// The Tabs trait provides common functionality for an enum to be used as a tabs menu UI component.
/// It requires the trait-user to implement action_on_click() and label().
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,7 +1,6 @@
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use serde_json::Value;
use strum_macros::EnumDiscriminants;
use strum_macros::EnumIter;
use strum_macros::{EnumDiscriminants, EnumIter};
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
@@ -1,12 +1,9 @@
#![allow(dead_code, unused_imports, unused_variables)]
use galaxyui::{
elements::{Align, Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text},
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
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;
+307 -264
View File
@@ -1,57 +1,56 @@
use super::{
editor_text_colors,
settings_page::{render_input_list, InputListItem},
};
use crate::server::server_api::ServerApiProvider;
use crate::{
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
ai::{
ambient_agents::github_auth_notifier::{GitHubAuthEvent, GitHubAuthNotifier},
cloud_environments::{AmbientAgentEnvironment, GithubRepo},
},
appearance::Appearance,
editor::{
EditorOptions, EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
},
root_view::CreateEnvironmentArg,
server::ids::SyncId,
ui_components::{buttons::icon_button, icons::Icon},
view_components::{
action_button::{ActionButton, DangerSecondaryTheme, PrimaryTheme},
render_warning_box, SubmittableTextInput, SubmittableTextInputEvent,
WarningBoxButtonConfig, WarningBoxConfig,
},
workspaces::user_workspaces::UserWorkspaces,
ChannelState,
};
#[cfg(not(target_family = "wasm"))]
use std::collections::HashMap;
use instant::{Duration, Instant};
use log::debug;
use url::Url;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_editor::editor::NavigationKey;
use galaxy_graphql::queries::user_github_info::UserGithubInfoResult;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty, Expanded,
Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode,
ScrollbarWidth, SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::geometry::vector::vec2f;
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::prelude::Coords;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty,
Expanded, Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, ScrollTarget,
ScrollToPositionMode, ScrollbarWidth, SizeConstraintCondition, SizeConstraintSwitch, Stack,
Text,
},
fonts::{Properties, Weight},
geometry::vector::vec2f,
keymap::FixedBinding,
platform::Cursor,
prelude::Coords,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use instant::{Duration, Instant};
use log::debug;
#[cfg(not(target_family = "wasm"))]
use std::collections::HashMap;
use url::Url;
use super::editor_text_colors;
use super::settings_page::{render_input_list, InputListItem};
use crate::ai::ambient_agents::github_auth_notifier::{GitHubAuthEvent, GitHubAuthNotifier};
use crate::ai::ambient_agents::github_auth_url::{self, AuthSource, GithubAuthRedirectTarget};
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
use crate::ai::cloud_environments::{AmbientAgentEnvironment, GithubRepo};
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::root_view::CreateEnvironmentArg;
use crate::server::ids::SyncId;
use crate::server::server_api::ServerApiProvider;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, DangerSecondaryTheme, PrimaryTheme, SecondaryTheme,
};
use crate::view_components::{
render_warning_box, SubmittableTextInput, SubmittableTextInputEvent, WarningBoxButtonConfig,
WarningBoxConfig,
};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::ChannelState;
const SUBMIT_BUTTON_FOCUSED: &str = "SubmitButtonFocused";
@@ -149,21 +148,6 @@ pub enum EnvironmentFormMode {
Edit { env_id: SyncId },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GithubAuthRedirectTarget {
SettingsEnvironments,
FocusCloudMode,
}
impl GithubAuthRedirectTarget {
fn next_path(self) -> &'static str {
match self {
Self::SettingsEnvironments => "settings/environments",
Self::FocusCloudMode => "action/focus_cloud_mode",
}
}
}
/// Events emitted by UpdateEnvironmentForm.
#[derive(Debug, Clone)]
pub enum UpdateEnvironmentFormEvent {
@@ -257,28 +241,60 @@ enum SuggestImageState {
},
}
/// Indicates where the GitHub authorization flow was initiated from.
/// This affects the redirect URL used after auth completes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AuthSource {
/// Auth initiated from the settings page (default behavior: redirect to settings)
#[default]
Settings,
/// Auth initiated from cloud agent setup (skip redirect, just refresh in place)
CloudSetup,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EnvironmentFormCopy {
name_placeholder: &'static str,
repos_placeholder_authed: &'static str,
repos_placeholder_unauthed: &'static str,
docker_image_label: &'static str,
docker_image_placeholder: &'static str,
description_placeholder: &'static str,
setup_commands_placeholder: &'static str,
setup_commands_helper: &'static str,
show_description_character_count: bool,
}
#[derive(Clone, Copy, Debug)]
enum OAuthNextPlatform {
Native,
Web,
impl EnvironmentFormCopy {
pub fn orchestration_modal() -> Self {
Self {
name_placeholder: "e.g., dev-env",
repos_placeholder_authed: "Browse GitHub repos...",
repos_placeholder_unauthed: REPOS_PLACEHOLDER_UNAUTHED,
docker_image_label: "Docker image",
docker_image_placeholder: "e.g., node:20-alpine",
description_placeholder: DESCRIPTION_PLACEHOLDER,
setup_commands_placeholder: "e.g., node start",
setup_commands_helper: "Press Enter or click the submit button to add each command.",
show_description_character_count: false,
}
}
}
impl Default for EnvironmentFormCopy {
fn default() -> Self {
Self {
name_placeholder: "Environment name",
repos_placeholder_authed: REPOS_PLACEHOLDER_AUTHED,
repos_placeholder_unauthed: REPOS_PLACEHOLDER_UNAUTHED,
docker_image_label: "Docker image reference",
docker_image_placeholder: "e.g. python:3.11, node:20-alpine",
description_placeholder: DESCRIPTION_PLACEHOLDER,
setup_commands_placeholder: "e.g. cd my-repo && pip install -r requirements.txt",
setup_commands_helper: "Setup commands run independently. Each command runs from the workspace root (/workspace). If a command depends on the previous one, combine them with &&.",
show_description_character_count: true,
}
}
}
pub struct UpdateEnvironmentForm {
mode: EnvironmentFormMode,
form_state: EnvironmentFormValues,
repos_input: String,
github_auth_redirect_target: GithubAuthRedirectTarget,
copy: EnvironmentFormCopy,
field_max_width: f32,
field_spacing: f32,
description_height: f32,
show_repo_helper_text: bool,
// Editor views
name_editor: ViewHandle<EditorView>,
@@ -293,6 +309,7 @@ pub struct UpdateEnvironmentForm {
// Action buttons
submit_button: ViewHandle<ActionButton>,
delete_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
back_button_mouse_state: MouseStateHandle,
// Share-with-team checkbox (Create mode only, when user is on a team)
@@ -332,6 +349,8 @@ pub struct UpdateEnvironmentForm {
/// When true (default), renders the header with back button, title, and submit button.
/// When false, skips the header and renders the submit button at the bottom-right of the form.
show_header: bool,
show_footer_cancel_button: bool,
show_share_with_team_controls: bool,
/// When true, pressing Escape in any editor will emit a Cancelled event.
/// This should only be enabled for contexts where the form is used as a modal (e.g., first-time setup).
@@ -343,6 +362,7 @@ pub struct UpdateEnvironmentForm {
}
const DESCRIPTION_MAX_CHARS: usize = 240;
const DESCRIPTION_PLACEHOLDER: &str = "e.g., this environment is for all front end focused agents";
const REPOS_PLACEHOLDER_AUTHED: &str = "Enter repos (owner/repo format)";
const REPOS_PLACEHOLDER_UNAUTHED: &str = "Paste repo URL(s)";
const FORM_FIELD_SPACING: f32 = 20.;
@@ -386,16 +406,18 @@ impl UpdateEnvironmentForm {
ctx.subscribe_to_model(&Appearance::handle(ctx), |form, _, _, ctx| {
form.update_editor_text_colors(ctx);
});
let copy = EnvironmentFormCopy::default();
// Create editors
let name_editor = Self::create_single_line_editor("Environment name", ctx);
let name_editor = Self::create_single_line_editor(copy.name_placeholder, ctx);
let description_editor = Self::create_description_editor(ctx);
let docker_image_editor =
Self::create_single_line_editor("e.g. python:3.11, node:20-alpine", ctx);
let repos_input_editor = Self::create_single_line_editor(REPOS_PLACEHOLDER_AUTHED, ctx);
Self::create_single_line_editor(copy.docker_image_placeholder, ctx);
let repos_input_editor =
Self::create_single_line_editor(copy.repos_placeholder_authed, ctx);
let setup_commands_input = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx);
input.set_placeholder_text("e.g. cd my-repo && pip install -r requirements.txt", ctx);
input.set_placeholder_text(copy.setup_commands_placeholder, ctx);
// Keep this consistent with other form inputs (e.g. repos): caller controls spacing.
input.set_outer_margins(0., 0., ctx);
input
@@ -456,6 +478,11 @@ impl UpdateEnvironmentForm {
})
});
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", SecondaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::Cancel);
})
});
// Set up editor subscriptions
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| match event {
crate::editor::Event::Edited(_) => {
@@ -590,6 +617,11 @@ impl UpdateEnvironmentForm {
form_state: EnvironmentFormValues::default(),
repos_input: String::new(),
github_auth_redirect_target: GithubAuthRedirectTarget::SettingsEnvironments,
copy,
field_max_width: DROPDOWN_MAX_WIDTH,
field_spacing: FORM_FIELD_SPACING,
description_height: FORM_DESCRIPTION_HEIGHT,
show_repo_helper_text: true,
name_editor,
description_editor,
docker_image_editor,
@@ -598,6 +630,7 @@ impl UpdateEnvironmentForm {
remove_setup_command_mouse_states: Vec::new(),
submit_button,
delete_button,
cancel_button,
back_button_mouse_state: MouseStateHandle::default(),
share_with_team: false,
share_with_team_checkbox_mouse_state: MouseStateHandle::default(),
@@ -622,6 +655,8 @@ impl UpdateEnvironmentForm {
image_link_button_mouse_state: MouseStateHandle::default(),
edit_repos_modified: false,
show_header: true,
show_footer_cancel_button: false,
show_share_with_team_controls: true,
should_handle_escape_from_editor: false,
auth_source: AuthSource::default(),
};
@@ -647,6 +682,79 @@ impl UpdateEnvironmentForm {
self.github_auth_redirect_target = target;
}
pub fn set_copy(&mut self, copy: EnvironmentFormCopy, ctx: &mut ViewContext<Self>) {
self.copy = copy;
self.name_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(copy.name_placeholder, ctx);
});
self.description_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(copy.description_placeholder, ctx);
});
self.docker_image_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(copy.docker_image_placeholder, ctx);
});
self.repos_input_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(copy.repos_placeholder_authed, ctx);
});
self.setup_commands_input.update(ctx, |input, ctx| {
input.set_placeholder_text(copy.setup_commands_placeholder, ctx);
});
self.update_repos_input_placeholder(ctx);
ctx.notify();
}
pub fn set_show_footer_cancel_button(&mut self, show: bool, ctx: &mut ViewContext<Self>) {
self.show_footer_cancel_button = show;
ctx.notify();
}
pub fn set_field_max_width(&mut self, width: f32, ctx: &mut ViewContext<Self>) {
self.field_max_width = width;
ctx.notify();
}
pub fn set_field_spacing(&mut self, spacing: f32, ctx: &mut ViewContext<Self>) {
self.field_spacing = spacing;
ctx.notify();
}
pub fn set_description_height(&mut self, height: f32, ctx: &mut ViewContext<Self>) {
self.description_height = height;
ctx.notify();
}
pub fn set_show_repo_helper_text(&mut self, show: bool, ctx: &mut ViewContext<Self>) {
self.show_repo_helper_text = show;
ctx.notify();
}
pub fn set_show_share_with_team_controls(&mut self, show: bool, ctx: &mut ViewContext<Self>) {
self.show_share_with_team_controls = show;
ctx.notify();
}
pub fn configure_for_orchestration_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.set_copy(EnvironmentFormCopy::orchestration_modal(), ctx);
self.show_footer_cancel_button = true;
self.show_share_with_team_controls = false;
self.field_spacing = 10.;
self.description_height = 52.;
self.show_repo_helper_text = false;
ctx.notify();
}
#[cfg(test)]
pub(crate) fn uses_orchestration_modal_configuration_for_test(&self) -> bool {
self.copy == EnvironmentFormCopy::orchestration_modal()
&& !self.show_header
&& self.show_footer_cancel_button
&& !self.show_share_with_team_controls
&& (self.field_spacing - 10.).abs() < f32::EPSILON
&& (self.description_height - 52.).abs() < f32::EPSILON
&& !self.show_repo_helper_text
&& self.github_auth_redirect_target == GithubAuthRedirectTarget::FocusCloudMode
&& self.auth_source == AuthSource::CloudSetup
&& self.should_handle_escape_from_editor
}
#[cfg(test)]
pub(crate) fn github_auth_redirect_target_for_test(&self) -> GithubAuthRedirectTarget {
self.github_auth_redirect_target
@@ -700,18 +808,7 @@ impl UpdateEnvironmentForm {
/// When `false`, the submit button is rendered at the bottom-right of the form instead.
pub fn set_show_header(&mut self, show_header: bool, ctx: &mut ViewContext<Self>) {
self.show_header = show_header;
// Update button text based on mode when header is hidden
if !show_header {
let button_text = match &self.mode {
EnvironmentFormMode::Create => "Create environment",
EnvironmentFormMode::Edit { .. } => "Save environment",
};
self.submit_button.update(ctx, |button, ctx| {
button.set_label(button_text, ctx);
});
}
self.update_submit_button_label(ctx);
ctx.notify();
}
@@ -733,6 +830,18 @@ impl UpdateEnvironmentForm {
ctx.focus(&self.name_editor);
}
fn update_submit_button_label(&mut self, ctx: &mut ViewContext<Self>) {
let button_text = match (&self.mode, self.show_header) {
(EnvironmentFormMode::Create, true) => "Create",
(EnvironmentFormMode::Create, false) => "Create environment",
(EnvironmentFormMode::Edit { .. }, true) => "Save",
(EnvironmentFormMode::Edit { .. }, false) => "Save environment",
};
self.submit_button.update(ctx, |button, ctx| {
button.set_label(button_text, ctx);
});
}
fn apply_mode(&mut self, init_args: &EnvironmentFormInitArgs, ctx: &mut ViewContext<Self>) {
match init_args {
EnvironmentFormInitArgs::Create => {
@@ -805,6 +914,8 @@ impl UpdateEnvironmentForm {
}
}
self.update_submit_button_label(ctx);
// Reset suggest image state for this session.
//
// Note: We intentionally do not set `suggest_image_last_attempt_key` here.
@@ -831,10 +942,12 @@ impl UpdateEnvironmentForm {
}
fn update_repos_input_placeholder(&mut self, ctx: &mut ViewContext<Self>) {
let placeholder = if self.github_dropdown_state.auth_url.is_some() {
REPOS_PLACEHOLDER_UNAUTHED
let placeholder = if self.github_dropdown_state.auth_url.is_some()
|| self.github_dropdown_state.load_error_message.is_some()
{
self.copy.repos_placeholder_unauthed
} else {
REPOS_PLACEHOLDER_AUTHED
self.copy.repos_placeholder_authed
};
self.repos_input_editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(placeholder, ctx);
@@ -914,16 +1027,12 @@ impl UpdateEnvironmentForm {
..Default::default()
};
let mut editor = EditorView::new(options, ctx);
editor.set_placeholder_text(
"e.g., this environment is for all front end focused agents",
ctx,
);
editor.set_placeholder_text(DESCRIPTION_PLACEHOLDER, ctx);
editor
})
}
fn parse_repo_input(input: &str) -> Option<(String, String)> {
use url::Url;
let trimmed = input.trim().trim_end_matches('/');
fn parse_owner_repo<'a, I>(mut segments: I) -> Option<(String, String)>
@@ -1244,12 +1353,19 @@ impl UpdateEnvironmentForm {
me.update_repos_input_placeholder(ctx);
}
Ok(UserGithubInfoResult::Unknown) => {
me.github_dropdown_state.load_error_message =
Some("Failed to load GitHub repos".to_string());
me.github_dropdown_state.load_error_message = Some(
"Couldn't load GitHub repos. You can paste repo URL(s), or retry."
.to_string(),
);
me.update_repos_input_placeholder(ctx);
}
Err(e) => {
me.github_dropdown_state.load_error_message =
Some(format!("Failed to load GitHub repos: {}", e));
debug!("Failed to load GitHub repos: {e}");
me.github_dropdown_state.load_error_message = Some(
"Couldn't load GitHub repos. You can paste repo URL(s), or retry."
.to_string(),
);
me.update_repos_input_placeholder(ctx);
}
}
@@ -1489,7 +1605,8 @@ impl UpdateEnvironmentForm {
}
fn should_show_share_with_team_checkbox(&self, app: &AppContext) -> bool {
matches!(self.mode, EnvironmentFormMode::Create)
self.show_share_with_team_controls
&& matches!(self.mode, EnvironmentFormMode::Create)
&& UserWorkspaces::as_ref(app).current_team_uid().is_some()
}
@@ -1571,7 +1688,7 @@ impl UpdateEnvironmentForm {
WarningBoxConfig::new(
"Personal environments cannot be used with external integrations or team API keys. For the best experience, use shared environments.",
)
.with_width(DROPDOWN_MAX_WIDTH),
.with_width(self.field_max_width),
appearance,
))
}
@@ -1759,10 +1876,12 @@ impl UpdateEnvironmentForm {
.cloned()
.unwrap_or_default(),
on_remove_action: UpdateEnvironmentFormAction::RemoveSetupCommand(index),
is_disabled: false,
tooltip_mouse_state: None,
});
let helper_text = Text::new(
"Setup commands run independently. Each command runs from the workspace root (/workspace). If a command depends on the previous one, combine them with &&.",
self.copy.setup_commands_helper,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
@@ -1779,7 +1898,7 @@ impl UpdateEnvironmentForm {
.with_child(helper_text)
.finish();
let list_items = render_input_list(None, items, None, false, appearance);
let list_items = render_input_list(None, items, None, appearance);
let list = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
@@ -1790,7 +1909,7 @@ impl UpdateEnvironmentForm {
field.add_child(
ConstrainedBox::new(Container::new(list).finish())
.with_max_width(DROPDOWN_MAX_WIDTH)
.with_max_width(self.field_max_width)
.finish(),
);
@@ -1830,28 +1949,29 @@ impl UpdateEnvironmentForm {
.with_background(theme.surface_2())
.finish(),
)
.with_min_height(FORM_DESCRIPTION_HEIGHT)
.with_min_height(self.description_height)
.finish();
field.add_child(editor_container);
// Character count display
let char_count = self
.description_editor
.as_ref(app)
.buffer_text(app)
.chars()
.count();
let count_text = format!("{} / {} characters", char_count, DESCRIPTION_MAX_CHARS);
field.add_child(
Text::new(
count_text,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
);
if self.copy.show_description_character_count {
let char_count = self
.description_editor
.as_ref(app)
.buffer_text(app)
.chars()
.count();
let count_text = format!("{char_count} / {DESCRIPTION_MAX_CHARS} characters");
field.add_child(
Text::new(
count_text,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
);
}
field.finish()
}
@@ -1890,12 +2010,10 @@ impl UpdateEnvironmentForm {
field.add_child(self.render_repos_field_label(appearance));
// Selected repo chips (if any)
if !self.form_state.selected_repos.is_empty() {
field.add_child(self.render_selected_repo_chips(appearance));
}
// Disabled input with loading placeholder
let loading_input = Container::new(
ConstrainedBox::new(
Flex::column()
@@ -1935,15 +2053,12 @@ impl UpdateEnvironmentForm {
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(FORM_LABEL_SPACING);
// Label
field.add_child(self.render_repos_field_label(appearance));
// Selected repo chips (if any)
if !self.form_state.selected_repos.is_empty() {
field.add_child(self.render_selected_repo_chips(appearance));
}
// Input for pasting repo URLs manually
let editor = Clipped::new(ChildView::new(&self.repos_input_editor).finish()).finish();
let input_container = Container::new(
@@ -2027,11 +2142,13 @@ impl UpdateEnvironmentForm {
.with_child(Expanded::new(1., input_container).finish())
.with_child(auth_button)
.finish();
field.add_child(
ConstrainedBox::new(row)
.with_max_width(DROPDOWN_MAX_WIDTH)
.with_max_width(self.field_max_width)
.finish(),
);
field.finish()
}
@@ -2048,30 +2165,25 @@ impl UpdateEnvironmentForm {
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(FORM_LABEL_SPACING);
// Label
field.add_child(self.render_repos_field_label(appearance));
// Selected repo chips (if any)
if !self.form_state.selected_repos.is_empty() {
field.add_child(self.render_selected_repo_chips(appearance));
}
let error_input = Container::new(
let editor = Clipped::new(ChildView::new(&self.repos_input_editor).finish()).finish();
let input_container = Container::new(
ConstrainedBox::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(
Container::new(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.ui_error_color())
.finish(),
Clipped::new(
Container::new(editor)
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
.finish(),
)
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
.finish(),
)
.finish(),
@@ -2142,15 +2254,25 @@ impl UpdateEnvironmentForm {
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(Expanded::new(1., error_input).finish())
.with_child(Expanded::new(1., input_container).finish())
.with_child(retry_button)
.finish();
field.add_child(
ConstrainedBox::new(row)
.with_max_width(DROPDOWN_MAX_WIDTH)
.with_max_width(self.field_max_width)
.finish(),
);
field.add_child(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size() * 0.85,
)
.soft_wrap(true)
.with_color(theme.ui_error_color())
.finish(),
);
field.finish()
}
@@ -2337,8 +2459,9 @@ impl UpdateEnvironmentForm {
field.add_child(input_row);
// Helper text
field.add_child(self.render_repo_helper_text_row(appearance));
if self.show_repo_helper_text {
field.add_child(self.render_repo_helper_text_row(appearance));
}
field.finish()
}
@@ -2646,7 +2769,7 @@ impl UpdateEnvironmentForm {
// Constrain height and width
let dropdown_content = ConstrainedBox::new(scrollable)
.with_max_height(DROPDOWN_MAX_HEIGHT)
.with_max_width(DROPDOWN_MAX_WIDTH)
.with_max_width(self.field_max_width)
.finish();
// Wrap in container with border and background
@@ -2713,13 +2836,17 @@ impl UpdateEnvironmentForm {
}
fn auth_url_with_next(&self, base_auth_url: &str) -> String {
let scheme = Self::oauth_next_scheme();
Self::build_auth_url_with_next_internal(
base_auth_url,
self.github_auth_redirect_target,
&scheme,
self.auth_source,
)
match (self.github_auth_redirect_target, self.auth_source) {
(GithubAuthRedirectTarget::SettingsEnvironments, AuthSource::Settings) => {
github_auth_url::settings_environments_auth_url_with_next(base_auth_url)
}
(GithubAuthRedirectTarget::FocusCloudMode, AuthSource::CloudSetup) => {
github_auth_url::cloud_setup_auth_url_with_next(base_auth_url)
}
(target, auth_source) => {
github_auth_url::auth_url_with_next(base_auth_url, target, auth_source)
}
}
}
#[cfg(test)]
@@ -2728,107 +2855,12 @@ impl UpdateEnvironmentForm {
target: GithubAuthRedirectTarget,
scheme: &str,
) -> String {
Self::build_auth_url_with_next_internal(base_auth_url, target, scheme, AuthSource::Settings)
}
fn build_auth_url_with_next_internal(
base_auth_url: &str,
target: GithubAuthRedirectTarget,
scheme: &str,
auth_source: AuthSource,
) -> String {
let Ok(mut url) = Url::parse(base_auth_url) else {
return base_auth_url.to_string();
};
let scheme_for_next = std::env::var("WARP_OAUTH_NEXT_SCHEME")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
url.query_pairs()
.find(|(key, _)| key == "scheme")
.map(|(_, value)| value.into_owned())
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| scheme.to_string());
let platform = if cfg!(target_family = "wasm") {
OAuthNextPlatform::Web
} else {
OAuthNextPlatform::Native
};
let next_url = Self::build_next_url(target, &scheme_for_next, auth_source, platform)
.unwrap_or_else(|| format!("{scheme_for_next}://{}", target.next_path()));
let existing_pairs = url
.query_pairs()
.filter(|(key, _)| key != "next")
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.clear();
for (key, value) in existing_pairs {
query_pairs.append_pair(&key, &value);
}
query_pairs.append_pair("next", &next_url);
}
url.to_string()
}
fn build_next_url(
target: GithubAuthRedirectTarget,
scheme_for_next: &str,
auth_source: AuthSource,
platform: OAuthNextPlatform,
) -> Option<String> {
match platform {
OAuthNextPlatform::Native => {
let base = format!("{scheme_for_next}://{}", target.next_path());
let mut url = Url::parse(&base).ok()?;
if matches!(auth_source, AuthSource::CloudSetup) {
url.query_pairs_mut()
.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
}
Some(url.to_string())
}
OAuthNextPlatform::Web => {
let mut url = Url::parse(&ChannelState::server_root_url()).ok()?;
url.set_query(None);
match target {
GithubAuthRedirectTarget::SettingsEnvironments => {
url.set_path("/settings/environments");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("oauth", "github");
if matches!(auth_source, AuthSource::CloudSetup) {
pairs.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
}
}
}
GithubAuthRedirectTarget::FocusCloudMode => {
url.set_path("/action/focus_cloud_mode");
}
}
Some(url.to_string())
}
}
}
fn oauth_next_scheme() -> String {
if let Ok(override_value) = std::env::var("WARP_OAUTH_NEXT_SCHEME") {
if !override_value.is_empty() {
return override_value;
}
}
ChannelState::url_scheme().to_string()
github_auth_url::build_auth_url_with_next(
base_auth_url,
target,
scheme,
AuthSource::Settings,
)
}
/// Parses a Docker image reference and returns the Docker Hub URL if it looks like a Docker Hub image.
@@ -2961,7 +2993,7 @@ impl UpdateEnvironmentForm {
// Label (without suggest button)
field.add_child(Self::render_form_label(
"Docker image reference",
self.copy.docker_image_label,
true,
appearance,
));
@@ -3009,7 +3041,7 @@ impl UpdateEnvironmentForm {
field.add_child(
ConstrainedBox::new(row)
.with_width(DROPDOWN_MAX_WIDTH)
.with_max_width(self.field_max_width)
.finish(),
);
@@ -3194,7 +3226,7 @@ impl UpdateEnvironmentForm {
WarningBoxConfig::new(
"You need to grant access to your GitHub repos to suggest a Docker image",
)
.with_width(DROPDOWN_MAX_WIDTH)
.with_width(self.field_max_width)
.with_button(button),
appearance,
))
@@ -3203,7 +3235,7 @@ impl UpdateEnvironmentForm {
if key == current_key =>
{
Some(render_warning_box(
WarningBoxConfig::new(message).with_width(DROPDOWN_MAX_WIDTH),
WarningBoxConfig::new(message).with_width(self.field_max_width),
appearance,
))
}
@@ -3231,7 +3263,7 @@ impl UpdateEnvironmentForm {
)
.with_description(reason)
.with_icon(Icon::AlertTriangle)
.with_width(DROPDOWN_MAX_WIDTH)
.with_width(self.field_max_width)
.with_button(button),
appearance,
)
@@ -3480,7 +3512,7 @@ impl View for UpdateEnvironmentForm {
let mut page = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(FORM_FIELD_SPACING);
.with_spacing(self.field_spacing);
// Header row with back button, title, and action button (only when show_header is true)
if self.show_header {
@@ -3518,8 +3550,19 @@ impl View for UpdateEnvironmentForm {
footer_row.add_child(Empty::new().finish());
}
// Submit actions on the right
footer_row.add_child(self.render_submit_actions(appearance, app, &self.submit_button));
let mut footer_actions = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.);
if self.show_footer_cancel_button {
footer_actions.add_child(ChildView::new(&self.cancel_button).finish());
}
footer_actions.add_child(self.render_submit_actions(
appearance,
app,
&self.submit_button,
));
footer_row.add_child(footer_actions.finish());
page.add_child(footer_row.finish());
} else if matches!(&self.mode, EnvironmentFormMode::Edit { .. }) {
@@ -1,22 +1,4 @@
use super::{
EnvironmentFormInitArgs, EnvironmentFormValues, GithubAuthRedirectTarget, SuggestImageState,
UpdateEnvironmentForm, UpdateEnvironmentFormAction,
};
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::ai::cloud_environments::GithubRepo;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ServerApiProvider;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team::Team;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::Workspace;
use url::Url;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::{Empty, MouseStateHandle};
use galaxyui::platform::WindowStyle;
@@ -24,7 +6,28 @@ use galaxyui::{
AddSingletonModel, App, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
WindowId,
};
use url::Url;
use super::{
EnvironmentFormCopy, EnvironmentFormInitArgs, EnvironmentFormValues, SuggestImageState,
UpdateEnvironmentForm, UpdateEnvironmentFormAction,
};
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
use crate::ai::ambient_agents::github_auth_url::{self, AuthSource, GithubAuthRedirectTarget};
use crate::ai::cloud_environments::GithubRepo;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team::Team;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::workspaces::workspace::Workspace;
#[test]
fn test_parse_repo_input_owner_repo() {
@@ -108,6 +111,25 @@ fn test_build_auth_url_with_next_focus_cloud_mode() {
);
}
#[test]
fn test_build_auth_url_with_next_cloud_setup_source() {
let base_url = "https://example.com/oauth/connect/github";
let result = github_auth_url::build_auth_url_with_next(
base_url,
GithubAuthRedirectTarget::FocusCloudMode,
"warpdev",
AuthSource::CloudSetup,
);
let parsed = Url::parse(&result).expect("result should be valid url");
let next_value = parsed
.query_pairs()
.find(|(key, _)| key == "next")
.map(|(_, value)| value.into_owned());
assert_eq!(
next_value,
Some("warpdev://action/focus_cloud_mode?source=cloud_setup".to_string())
);
}
#[test]
fn test_build_auth_url_with_next_uses_scheme_param() {
let base_url = "https://example.com/oauth/connect/github?scheme=warp";
@@ -244,6 +266,7 @@ fn workspace_for_test(team: &Team) -> Workspace {
teams: vec![team.clone()],
billing_metadata: Default::default(),
bonus_grants_purchased_this_month: Default::default(),
billing_cycle_usage: None,
has_billing_history: false,
settings: Default::default(),
invite_code: None,
@@ -503,6 +526,42 @@ fn test_render_repos_field_error_state() {
})
}
#[test]
fn test_repos_field_error_state_allows_manual_repo_entry() {
App::test((), |mut app| async move {
init_update_environment_form_test_models(&mut app);
let window_id = create_test_window(&mut app);
let mut view_handle = None;
app.update(|ctx| {
view_handle = Some(ctx.add_typed_action_view(window_id, |ctx| {
UpdateEnvironmentForm::new_for_test(EnvironmentFormInitArgs::Create, ctx)
}));
});
let view_handle = view_handle.expect("UpdateEnvironmentForm handle should be created");
app.update(|ctx| {
view_handle.update(ctx, |form, ctx| {
set_github_auth_call_state(
form,
GithubAuthCallState::error("Failed to load GitHub repositories"),
);
form.repos_input = "warpdotdev/warp-internal".to_string();
form.handle_action(&UpdateEnvironmentFormAction::AddRepo, ctx);
});
let form = view_handle.as_ref(ctx);
assert_eq!(form.form_state.selected_repos.len(), 1);
assert_eq!(form.form_state.selected_repos[0].owner, "warpdotdev");
assert_eq!(form.form_state.selected_repos[0].repo, "warp-internal");
assert!(
form.github_dropdown_state.load_error_message.is_some(),
"Expected GitHub load error to remain visible after manually adding a repo"
);
});
})
}
#[test]
fn test_render_repos_field_with_selected_repos() {
App::test((), |mut app| async move {
@@ -963,6 +1022,112 @@ fn test_create_environment_form_without_team_does_not_render_checkbox_and_defaul
})
}
#[test]
fn test_environment_form_copy_orchestration_modal_overrides_settings_defaults() {
let default_copy = EnvironmentFormCopy::default();
let orchestration_copy = EnvironmentFormCopy::orchestration_modal();
assert_eq!(default_copy.name_placeholder, "Environment name");
assert_eq!(default_copy.docker_image_label, "Docker image reference");
assert!(default_copy.show_description_character_count);
assert_eq!(orchestration_copy.name_placeholder, "e.g., dev-env");
assert_eq!(
orchestration_copy.repos_placeholder_authed,
"Browse GitHub repos..."
);
assert_eq!(orchestration_copy.docker_image_label, "Docker image");
assert_eq!(
orchestration_copy.docker_image_placeholder,
"e.g., node:20-alpine"
);
assert_eq!(
orchestration_copy.setup_commands_placeholder,
"e.g., node start"
);
assert_eq!(
orchestration_copy.setup_commands_helper,
"Press Enter or click the submit button to add each command."
);
assert!(!orchestration_copy.show_description_character_count);
}
#[test]
fn test_orchestration_modal_form_configuration_renders_footer_actions_without_team_controls() {
App::test((), |mut app| async move {
init_update_environment_form_test_models(&mut app);
let window_id = create_test_window(&mut app);
app.update(|ctx| {
let team = team_for_test();
let workspace = workspace_for_test(&team);
let workspace_uid = workspace.uid;
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.update_workspaces(vec![workspace], ctx);
user_workspaces.set_current_workspace_uid(workspace_uid, ctx);
});
let view_handle = ctx.add_typed_action_view(window_id, |ctx| {
let mut form =
UpdateEnvironmentForm::new_for_test(EnvironmentFormInitArgs::Create, ctx);
form.set_show_header(false, ctx);
form.configure_for_orchestration_modal(ctx);
form
});
let form = view_handle.as_ref(ctx);
assert!(!form.show_header);
assert!(form.show_footer_cancel_button);
assert!(!form.show_share_with_team_controls);
assert_eq!(form.field_spacing, 10.);
assert_eq!(form.description_height, 52.);
assert!(!form.show_repo_helper_text);
assert!(!form.copy.show_description_character_count);
let submit_button = form.submit_button.clone();
let cancel_button = form.cancel_button.clone();
let submit_text = submit_button
.as_ref(ctx)
.render(ctx)
.debug_text_content()
.unwrap_or_default();
let cancel_text = cancel_button
.as_ref(ctx)
.render(ctx)
.debug_text_content()
.unwrap_or_default();
assert!(
submit_text.contains("Create environment"),
"Expected footer submit label in rendered content: {submit_text}"
);
assert!(
cancel_text.contains("Cancel"),
"Expected footer cancel action in rendered content: {cancel_text}"
);
let text_content = view_handle
.as_ref(ctx)
.render(ctx)
.debug_text_content()
.unwrap_or_default();
assert!(
!text_content.contains("Share with team"),
"Did not expect team-sharing controls in orchestration modal form: {text_content}"
);
assert!(
!text_content.contains("0 / 240 characters"),
"Did not expect settings character count in orchestration modal form: {text_content}"
);
assert!(
!text_content.contains("Type owner/repo and press Enter"),
"Did not expect settings repo helper text in orchestration modal form: {text_content}"
);
});
})
}
#[test]
fn test_parse_docker_hub_url_bare_owner_repo() {
assert_eq!(
+45 -16
View File
@@ -1,23 +1,30 @@
use super::{
settings_page::{
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
},
LocalOnlyIconState, SettingsSection, ToggleState,
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_if_error;
use galaxy_core::settings::ToggleableSetting as _;
use warpui::elements::{
Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text,
};
use crate::{appearance::Appearance, auth::AuthStateProvider, drive::settings::WarpDriveSettings};
use galaxy_core::{features::FeatureFlag, report_if_error, settings::ToggleableSetting as _};
use galaxyui::fonts::Weight;
use galaxyui::keymap::ContextPredicate;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
elements::{Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text},
fonts::Weight,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use super::settings_page::{
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
SettingsPageViewHandle, SettingsWidget,
};
use super::{
flags, LocalOnlyIconState, SettingActionPairContexts, SettingActionPairDescriptions,
SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState,
};
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::drive::settings::WarpDriveSettings;
#[derive(Debug, Clone)]
pub enum WarpDriveSettingsPageAction {
ToggleShowWarpDrive,
@@ -25,6 +32,28 @@ pub enum WarpDriveSettingsPageAction {
OpenUrl(String),
}
pub fn init_actions_from_parent_view<T: Action + Clone>(
app: &mut AppContext,
context: &ContextPredicate,
builder: fn(SettingsAction) -> T,
) {
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
vec![ToggleSettingActionPair::custom(
SettingActionPairDescriptions::new("Enable Warp Drive", "Disable Warp Drive"),
builder(SettingsAction::WarpDrive(
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
)),
SettingActionPairContexts::new(
context.clone() & !id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
context.clone() & id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
),
None,
)
.with_enabled(|| FeatureFlag::OpenWarpNewSettingsModes.is_enabled())],
app,
);
}
pub enum WarpDriveSettingsPageEvent {
SignUp,
}
+67 -156
View File
@@ -2,50 +2,41 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt::Display;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{FormattedTextElement, HighlightedHyperlink};
use galaxyui::keymap::ContextPredicate;
use galaxyui::{
elements::{Container, Flex, MouseStateHandle, ParentElement},
presenter::ChildView,
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use regex::Regex;
use settings::{Setting, ToggleableSetting};
use strum::IntoEnumIterator;
use crate::terminal::warpify::settings::{
EnableSshWarpification, SshExtensionInstallMode, UseSshTmuxWrapper, WarpifySettingsChangedEvent,
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
Container, Flex, FormattedTextElement, HighlightedHyperlink, MouseStateHandle, ParentElement,
};
use crate::ui_components::blended_colors;
use crate::{
appearance::Appearance,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
terminal::warpify::settings::WarpifySettings,
view_components::{SubmittableTextInput, SubmittableTextInputEvent},
use galaxyui::keymap::ContextPredicate;
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use super::settings_page::{
render_body_item, render_dropdown_item, render_page_title, AdditionalInfo, Category,
LocalOnlyIconState, MatchData, PageType, SettingsPageEvent, SettingsWidget, ToggleState,
HEADER_FONT_SIZE, HEADER_PADDING,
add_setting, render_alternating_color_list, render_body_item, render_dropdown_item,
render_page_title, Category, LocalOnlyIconState, MatchData, PageType, SettingsPageEvent,
SettingsPageMeta, SettingsPageViewHandle, SettingsWidget, ToggleState, HEADER_FONT_SIZE,
HEADER_PADDING,
};
use super::SettingsSection;
use super::{
flags,
settings_page::{
add_setting, render_alternating_color_list, SettingsPageMeta, SettingsPageViewHandle,
},
SettingsAction, ToggleSettingActionPair,
use super::{flags, SettingsAction, SettingsSection, ToggleSettingActionPair};
use crate::appearance::Appearance;
use crate::server::telemetry::TelemetryEvent;
use crate::settings::{ReuseExistingSshControlMaster, SshSettings};
use crate::terminal::warpify::settings::{
EnableSshWarpification, SshExtensionInstallMode, SshExtensionInstallModeSetting,
WarpifySettings, WarpifySettingsChangedEvent,
};
use crate::ui_components::blended_colors;
use crate::view_components::dropdown::{Dropdown, DropdownItem};
use crate::view_components::{SubmittableTextInput, SubmittableTextInputEvent};
use crate::{report_if_error, send_telemetry_from_ctx};
pub fn init_actions_from_parent_view<T: Action + Clone>(
app: &mut AppContext,
@@ -54,15 +45,17 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
) {
// Add all of the toggle settings from the Warpify Page that you want to show up on the Command Palette here.
let mut toggle_binding_pairs = vec![];
if FeatureFlag::SSHTmuxWrapper.is_enabled() {
if WarpifySettings::as_ref(app)
.enable_ssh_warpification
.is_supported_on_current_platform()
{
toggle_binding_pairs.push(ToggleSettingActionPair::new(
"SSH session detection for Warpification",
"SSH Warpification",
builder(SettingsAction::WarpifyPageToggle(
WarpifyPageAction::ToggleTmuxWarpification,
WarpifyPageAction::ToggleSshWarpification,
)),
context,
flags::SSH_TMUX_WRAPPER_CONTEXT_FLAG,
flags::SSH_WARPIFICATION_CONTEXT_FLAG,
));
}
@@ -75,7 +68,7 @@ const ITEM_VERTICAL_SPACING: f32 = 24.;
const BUILT_IN_TEXT_INPUT_MARGIN: f32 = 10.;
const SPACE_AFTER_TEXT_INPUT: f32 = ITEM_VERTICAL_SPACING - BUILT_IN_TEXT_INPUT_MARGIN;
const SSH_TMUX_WARPIFICATION_DESCRIPTION: &str = "The tmux ssh wrapper works in many situations where the default one does not, but may require you to hit a button to galaxify. Takes effect in new tabs.";
const SSH_REUSE_CONTROL_MASTER_DESCRIPTION: &str = "Attach to a live SSH ControlMaster you already have configured for the destination host instead of creating a Warp-owned one. Takes effect in new tabs.";
const SSH_EXTENSION_INSTALL_MODE_DESCRIPTION: &str =
"Controls the installation behavior for Galaxy's SSH extension when a remote host doesn't have it installed.";
@@ -94,9 +87,6 @@ pub struct WarpifyPageView {
remove_denylisted_command_button_states: Vec<MouseStateHandle>,
add_denylisted_commands_editor: ViewHandle<SubmittableTextInput>,
remove_denylisted_ssh_button_states: Vec<MouseStateHandle>,
add_denylisted_ssh_editor: ViewHandle<SubmittableTextInput>,
ssh_extension_install_mode_dropdown: ViewHandle<Dropdown<WarpifyPageAction>>,
}
@@ -109,7 +99,7 @@ impl WarpifyPageView {
me.update_button_states(model, ctx);
if matches!(
event,
WarpifySettingsChangedEvent::SshExtensionInstallMode { .. }
WarpifySettingsChangedEvent::SshExtensionInstallModeSetting { .. }
) {
me.update_dropdown(ctx);
}
@@ -141,17 +131,6 @@ impl WarpifyPageView {
Self::handle_denylisted_command_editor_event,
);
let add_denylisted_ssh_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx);
input.set_placeholder_text("host (supports regex)", ctx);
input
});
ctx.subscribe_to_view(
&add_denylisted_ssh_editor,
Self::handle_denylisted_ssh_editor_event,
);
let ssh_extension_install_mode_dropdown =
Self::create_ssh_extension_install_mode_dropdown(ctx);
@@ -161,8 +140,6 @@ impl WarpifyPageView {
add_added_commands_editor,
remove_denylisted_command_button_states: Default::default(),
add_denylisted_commands_editor,
remove_denylisted_ssh_button_states: Default::default(),
add_denylisted_ssh_editor,
ssh_extension_install_mode_dropdown,
};
@@ -178,10 +155,9 @@ impl WarpifyPageView {
];
let warpify_settings = WarpifySettings::as_ref(ctx);
if FeatureFlag::SSHTmuxWrapper.is_enabled()
&& warpify_settings
.enable_ssh_warpification
.is_supported_on_current_platform()
if warpify_settings
.enable_ssh_warpification
.is_supported_on_current_platform()
{
categories.push(
Category::new("SSH", vec![Box::new(SSHWidget::default())])
@@ -209,11 +185,6 @@ impl WarpifyPageView {
.iter()
.map(|_| Default::default())
.collect();
self.remove_denylisted_ssh_button_states = warpify_settings
.ssh_hosts_denylist
.iter()
.map(|_| Default::default())
.collect();
ctx.notify();
}
@@ -269,24 +240,6 @@ impl WarpifyPageView {
}
}
fn handle_denylisted_ssh_editor_event(
&mut self,
_handle: ViewHandle<SubmittableTextInput>,
event: &SubmittableTextInputEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SubmittableTextInputEvent::Submit(new_command) => {
WarpifySettings::handle(ctx).update(ctx, |warpify_settings, ctx| {
warpify_settings.denylist_ssh_host(new_command, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::AddDenylistedSshTmuxWrapperHost, ctx);
}
SubmittableTextInputEvent::Escape => ctx.emit(SettingsPageEvent::FocusModal),
}
}
fn remove_denylisted_command(&self, index: usize, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(TelemetryEvent::RemoveDenylistedSubshellCommand, ctx);
WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| {
@@ -300,13 +253,6 @@ impl WarpifyPageView {
warpify.remove_added_subshell_command(index, ctx)
});
}
fn remove_denylisted_ssh_host(&self, index: usize, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(TelemetryEvent::RemoveDenylistedSshTmuxWrapperHost, ctx);
WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| {
warpify.remove_denylisted_ssh_host(index, ctx)
});
}
}
impl Entity for WarpifyPageView {
@@ -420,10 +366,10 @@ impl View for WarpifyPageView {
pub enum WarpifyPageAction {
RemoveAddedCommand(usize),
RemoveDenylistedCommand(usize),
RemoveDenylistedSshHost(usize),
/// If disabled, auto-Warpification and the SSH Warpification prompt will be disabled.
ToggleTmuxWarpification,
ToggleSshWarpification,
/// Toggles whether the legacy SSH wrapper attaches to an existing
/// ControlMaster for the destination host instead of creating its own.
ToggleReuseSshControlMaster,
/// Set the SSH extension installation mode (always ask / always install / always skip).
SetSshExtensionInstallMode(SshExtensionInstallMode),
OpenUrl(String),
@@ -461,12 +407,18 @@ impl TypedActionView for WarpifyPageView {
}
});
}
ToggleTmuxWarpification => {
WarpifySettings::handle(ctx).update(ctx, |ssh_settings, ctx| {
report_if_error!(ssh_settings.use_ssh_tmux_wrapper.toggle_and_save_value(ctx));
ToggleReuseSshControlMaster => {
SshSettings::handle(ctx).update(ctx, |ssh_settings, ctx| {
report_if_error!(ssh_settings
.reuse_existing_control_master
.toggle_and_save_value(ctx));
send_telemetry_from_ctx!(
TelemetryEvent::ToggleSshTmuxWrapper {
enabled: *ssh_settings.use_ssh_tmux_wrapper.value(),
TelemetryEvent::FeaturesPageAction {
action: "ToggleSshReuseControlMaster".to_string(),
value: ssh_settings
.reuse_existing_control_master
.value()
.to_string(),
},
ctx
);
@@ -485,9 +437,6 @@ impl TypedActionView for WarpifyPageView {
);
});
}
WarpifyPageAction::RemoveDenylistedSshHost(index) => {
self.remove_denylisted_ssh_host(*index, ctx);
}
OpenUrl(url) => {
ctx.open_url(url.as_str());
}
@@ -645,9 +594,8 @@ impl SettingsWidget for SubshellsWidget {
#[derive(Default)]
struct SSHWidget {
tmux_warpification_switch_state: SwitchStateHandle,
enable_ssh_warpification_switch_state: SwitchStateHandle,
additional_info_mouse_state: MouseStateHandle,
reuse_control_master_switch_state: SwitchStateHandle,
local_only_icon_tooltip_states: RefCell<HashMap<String, MouseStateHandle>>,
}
@@ -674,9 +622,6 @@ impl SettingsWidget for SSHWidget {
.enable_ssh_warpification
.value();
let should_prompt_ssh_tmux_wrapper =
*WarpifySettings::as_ref(app).use_ssh_tmux_wrapper.value();
add_setting(
&mut column,
&WarpifySettings::as_ref(app).enable_ssh_warpification,
@@ -721,8 +666,8 @@ impl SettingsWidget for SSHWidget {
Some(SSH_EXTENSION_INSTALL_MODE_DESCRIPTION),
None,
LocalOnlyIconState::for_setting(
SshExtensionInstallMode::storage_key(),
SshExtensionInstallMode::sync_to_cloud(),
SshExtensionInstallModeSetting::storage_key(),
SshExtensionInstallModeSetting::sync_to_cloud(),
&mut self.local_only_icon_tooltip_states.borrow_mut(),
app,
),
@@ -735,49 +680,44 @@ impl SettingsWidget for SSHWidget {
);
}
let reuse_existing_control_master = *SshSettings::as_ref(app)
.reuse_existing_control_master
.value();
add_setting(
&mut column,
&WarpifySettings::as_ref(app).use_ssh_tmux_wrapper,
&SshSettings::as_ref(app).reuse_existing_control_master,
move || {
let mut column = Flex::column();
column.add_child(render_body_item::<WarpifyPageAction>(
"Use Tmux Warpification".into(),
Some(AdditionalInfo {
mouse_state: self.additional_info_mouse_state.clone(),
on_click_action: Some(WarpifyPageAction::OpenUrl(
"https://docs.warp.dev/terminal/warpify/ssh".into(),
)),
secondary_text: None,
tooltip_override_text: None,
}),
"Reuse existing SSH ControlMaster".into(),
None,
LocalOnlyIconState::for_setting(
UseSshTmuxWrapper::storage_key(),
UseSshTmuxWrapper::sync_to_cloud(),
ReuseExistingSshControlMaster::storage_key(),
ReuseExistingSshControlMaster::sync_to_cloud(),
&mut self.local_only_icon_tooltip_states.borrow_mut(),
app,
),
enable_ssh_warpification.into(),
appearance,
ui_builder
.switch(self.tmux_warpification_switch_state.clone())
.check(should_prompt_ssh_tmux_wrapper)
.switch(self.reuse_control_master_switch_state.clone())
.check(reuse_existing_control_master)
.with_disabled(!enable_ssh_warpification)
.build()
.on_click(move |ctx, _, _| {
if !enable_ssh_warpification {
return;
}
ctx.dispatch_typed_action(WarpifyPageAction::ToggleTmuxWarpification);
ctx.dispatch_typed_action(
WarpifyPageAction::ToggleReuseSshControlMaster,
);
})
.finish(),
None,
));
column.add_child(
ui_builder
.paragraph(SSH_TMUX_WARPIFICATION_DESCRIPTION.to_owned())
.paragraph(SSH_REUSE_CONTROL_MASTER_DESCRIPTION.to_owned())
.with_style(UiComponentStyles {
font_color: Some(description_text_color.into_solid()),
margin: Some(
@@ -790,29 +730,6 @@ impl SettingsWidget for SSHWidget {
.build()
.finish(),
);
if enable_ssh_warpification && should_prompt_ssh_tmux_wrapper {
let warpify_settings = WarpifySettings::as_ref(app);
column.add_child(
view.build_input_list(
"Denylisted hosts",
&warpify_settings.ssh_hosts_denylist,
&view.remove_denylisted_ssh_button_states,
WarpifyPageAction::RemoveDenylistedSshHost,
&view.add_denylisted_ssh_editor,
appearance,
)
.finish(),
);
} else {
// Add margin to hint the user should scroll to see more.
column.add_child(
Container::new(Flex::column().finish())
.with_margin_bottom(styles::MINIMUM_SCROLL_OFFSET_AFTER_SSH)
.finish(),
);
}
column.finish()
},
);
@@ -828,10 +745,4 @@ mod styles {
/// The space after a description.
pub const DESCRIPTION_LINE_MARGIN_BOTTOM: f32 = 18.;
/// Because we hide the SSH settings if the SSH wrapper is disabled, we need to add a margin
/// to the bottom to make it clear that toggling this item will reveal more settings,
/// even at smaller window sizes. We picked an offset that cuts off the first item
/// to imply the user should scroll to see more.
pub const MINIMUM_SCROLL_OFFSET_AFTER_SSH: f32 = 40.;
}