first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3320
-228
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)));
|
||||
}
|
||||
@@ -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,
|
||||
¤t_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
+737
-240
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
@@ -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.;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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().
|
||||
|
||||
+927
-558
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user