use self::telemetry::SettingsTelemetryEvent; 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, }, server::server_api::ServerApiProvider, 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 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 environments_page::EnvironmentsPageView; use features_page::{FeaturesPageView, FeaturesSettingsPageEvent}; use itertools::Itertools as _; use keybindings::KeybindingsView; use main_page::{MainPageAction, MainSettingsPageEvent, MainSettingsPageView}; 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 settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates}; use settings_page::{ MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle, HEADER_PADDING, }; use show_blocks_view::{ShowBlocksEvent, ShowBlocksView}; use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; use teams_page::{TeamsPageView, TeamsPageViewEvent}; use warp_core::send_telemetry_from_ctx; use warp_core::{ channel::ChannelState, context_flag::ContextFlag, features::FeatureFlag, settings::ToggleableSetting as _, ui::theme::color::internal_colors, }; use warp_editor::editor::NavigationKey; use warpify_page::{WarpifyPageAction, WarpifyPageView}; use warpui::Element; use warpui::{ 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, }; mod about_page; mod admin_actions; mod agent_assisted_environment_modal; mod ai_page; mod appearance_page; mod billing_and_usage; mod billing_and_usage_page; mod code_page; 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 mod keybindings; mod main_page; pub mod mcp_servers; pub mod mcp_servers_page; mod nav; pub mod pane_manager; mod platform; mod platform_page; mod privacy; mod privacy_page; mod referrals_page; mod settings_file_footer; pub(crate) mod settings_page; mod show_blocks_view; mod tab_menu; mod teams_page; mod telemetry; mod transfer_ownership_confirmation_modal; pub mod update_environment_form; mod warp_drive_page; mod warpify_page; #[cfg(not(target_family = "wasm"))] pub(crate) use ai_page::cli_agent_settings_widget_id; pub use billing_and_usage_page::create_discount_badge; pub use code_page::CodeSettingsPageView; pub use features_page::FeaturesPageAction; pub use main_page::handle_experiment_change; pub use privacy_page::PrivacyPageAction; pub use settings_page::{ render_body_item_label, render_info_icon, render_input_list, render_separator, AdditionalInfo, InputListItem, LocalOnlyIconState, ToggleState, }; pub use teams_page::{OpenTeamsSettingsModalArgs, TeamsInviteOption}; /// Original sidebar width used when the settings-file footer is not /// enabled. Preserved for Preview/Stable until `FeatureFlag::SettingsFile` /// is promoted. const SIDEBAR_WIDTH_DEFAULT: f32 = 200.; /// Wider sidebar used when the settings-file footer is enabled. Sized to /// match Figma's settings nav rail (223px alert + 12px horizontal padding /// on each side + 1px right border), giving the error-alert footer enough /// room to render its "Open file" and "Fix with Oz" buttons side-by-side /// with the designed 24px indent and 8px internal padding. const SIDEBAR_WIDTH_WITH_FOOTER: f32 = 248.; /// Returns the sidebar width, widened only when the settings-file footer /// is enabled. This keeps the wider layout gated with the footer itself so /// Preview/Stable users don't see an unexplained 48px width bump before /// the feature ships. fn sidebar_width() -> f32 { if FeatureFlag::SettingsFile.is_enabled() { SIDEBAR_WIDTH_WITH_FOOTER } else { SIDEBAR_WIDTH_DEFAULT } } /// Width of the borders for the header and the sidebar. const SECTION_BORDER_WIDTH: f32 = 1.; const POSITION_ID: &str = "settings_pane"; pub(super) fn editor_text_colors(appearance: &Appearance) -> TextColors { let theme = appearance.theme(); TextColors { default_color: theme.active_ui_text_color(), disabled_color: theme.disabled_ui_text_color(), hint_color: theme.disabled_ui_text_color(), } } #[derive(PartialEq, Eq)] pub enum SettingsViewEvent { Pane(PaneEvent), StartResize, CheckForUpdate, LaunchNetworkLogging, OpenWarpDrive, SignupAnonymousUser, ShowToast { message: String, flavor: ToastFlavor, }, OpenAIFactCollection, OpenMCPServerCollection, OpenExecutionProfileEditor(ClientProfileId), OpenLspLogs { log_path: PathBuf, }, OpenProjectRulesPane { rule_paths: Vec, }, } /// Different navigation sections within the settings view #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub enum SettingsSection { About, #[default] Account, MCPServers, BillingAndUsage, Appearance, Features, Keybindings, Privacy, Referrals, SharedBlocks, Teams, WarpDrive, Warpify, /// Internal backing-page identifier for AISettingsPageView. Multiple subpages /// (WarpAgent, AgentProfiles, Knowledge, ThirdPartyCLIAgents) share this single /// backing page, so this variant is needed as the key in `settings_pages`. /// External callers should navigate to a specific subpage (e.g. `WarpAgent`) instead. AI, // ── Agents umbrella subpages ── WarpAgent, AgentProfiles, AgentMCPServers, Knowledge, ThirdPartyCLIAgents, /// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages /// (CodeIndexing, EditorAndCodeReview) share this single backing page, /// so this variant is needed as the key in `settings_pages`. /// External callers should navigate to a specific subpage instead. Code, // ── Code umbrella subpages ── CodeIndexing, EditorAndCodeReview, // ── Cloud platform umbrella subpages ── CloudEnvironments, OzCloudAPIKeys, } use crate::util::bindings::custom_tag_to_keystroke; use std::fmt::{self, Display}; 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::SharedBlocks => write!(f, "Shared blocks"), SettingsSection::MCPServers => write!(f, "MCP Servers"), SettingsSection::WarpDrive => write!(f, "Warp Drive"), SettingsSection::WarpAgent => write!(f, "Warp Agent"), SettingsSection::AgentProfiles => write!(f, "Profiles"), SettingsSection::AgentMCPServers => write!(f, "MCP servers"), SettingsSection::Knowledge => write!(f, "Knowledge"), SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"), SettingsSection::CodeIndexing => write!(f, "Indexing and projects"), SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"), SettingsSection::CloudEnvironments => write!(f, "Environments"), SettingsSection::OzCloudAPIKeys => write!(f, "Oz Cloud API Keys"), _ => write!(f, "{self:?}"), } } } impl SettingsSection { /// Returns true if this section is a subpage under any umbrella. pub fn is_subpage(&self) -> bool { self.is_ai_subpage() || self.is_code_subpage() || self.is_cloud_platform_subpage() } /// Returns true if this section is a subpage under the "Agents" umbrella. pub fn is_ai_subpage(&self) -> bool { matches!( self, Self::WarpAgent | Self::AgentProfiles | Self::AgentMCPServers | Self::Knowledge | Self::ThirdPartyCLIAgents ) } /// Returns true if this section is a subpage under the "Code" umbrella. pub fn is_code_subpage(&self) -> bool { matches!(self, Self::CodeIndexing | Self::EditorAndCodeReview) } /// Returns true if this section is a subpage under the "Cloud platform" umbrella. pub fn is_cloud_platform_subpage(&self) -> bool { matches!(self, Self::CloudEnvironments | Self::OzCloudAPIKeys) } /// Maps subpage sections back to their parent page section for page lookup. /// Non-subpage sections return themselves. pub fn parent_page_section(&self) -> Self { match self { // AgentMCPServers renders the standalone MCPServers page directly. Self::AgentMCPServers => Self::MCPServers, // All other AI subpages render within the AI page. s if s.is_ai_subpage() => Self::AI, // Code subpages render within the Code page. s if s.is_code_subpage() => Self::Code, // CloudEnvironments and OzCloudAPIKeys ARE their own backing pages // (1:1 mapping), so they return themselves. other => *other, } } /// The ordered list of AI subpage sections shown under the Agents umbrella. pub fn ai_subpages() -> &'static [Self] { &[ Self::WarpAgent, Self::AgentProfiles, Self::AgentMCPServers, Self::Knowledge, Self::ThirdPartyCLIAgents, ] } /// The ordered list of Code subpage sections shown under the Code umbrella. pub fn code_subpages() -> &'static [Self] { &[Self::CodeIndexing, Self::EditorAndCodeReview] } /// The ordered list of Cloud platform subpage sections. pub fn cloud_platform_subpages() -> &'static [Self] { &[Self::CloudEnvironments, Self::OzCloudAPIKeys] } } impl FromStr for SettingsSection { type Err = (); fn from_str(s: &str) -> Result { match s { "About" => Ok(Self::About), "Account" => Ok(Self::Account), "AI" => Ok(Self::AI), "MCP Servers" => Ok(Self::MCPServers), "Billing and usage" => Ok(Self::BillingAndUsage), "Appearance" => Ok(Self::Appearance), "Code" => Ok(Self::Code), "Features" => Ok(Self::Features), "Keyboard shortcuts" => Ok(Self::Keybindings), "Privacy" => Ok(Self::Privacy), "Referrals" => Ok(Self::Referrals), "Shared blocks" => Ok(Self::SharedBlocks), "Teams" => Ok(Self::Teams), "Warpify" => Ok(Self::Warpify), "WarpDrive" | "Warp Drive" => Ok(Self::WarpDrive), // This page was called "Oz" at one point, keep for backward compatibility. "Oz" | "Warp Agent" => Ok(Self::WarpAgent), "Profiles" | "AgentProfiles" => Ok(Self::AgentProfiles), "MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers), "Knowledge" => Ok(Self::Knowledge), "Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents), "Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing), "Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview), "CloudEnvironments" => Ok(Self::CloudEnvironments), "Oz Cloud API Keys" | "OzCloudAPIKeys" => Ok(Self::OzCloudAPIKeys), _ => Err(()), } } } pub struct DisplayCount(pub usize); impl Entity for DisplayCount { type Event = (); } impl SingletonEntity for DisplayCount {} impl DisplayCount { pub fn num_displays(&self) -> usize { self.0 } #[cfg(test)] pub fn mock() -> Self { Self(1) } } pub mod flags { // The following are context flags to determine if the enable or disable binding is shown. pub const COPY_ON_SELECT_CONTEXT_FLAG: &str = "Copy_On_Select"; pub const LINUX_SELECTION_CLIPBOARD_FLAG: &str = "Linux_Selection_Clipboard"; pub const RESTORE_SESSION_CONTEXT_FLAG: &str = "Restore_Sessions"; pub const HONOR_PS1_CONTEXT_FLAG: &str = "Honor_PS1"; pub const GIT_PROMPT_CONTEXT_FLAG: &str = "Git_Prompt"; pub const AUTOCOMPLETE_SYMBOLS_CONTEXT_FLAG: &str = "Autocomplete_Symbols"; pub const QUAKE_MODE_ENABLED_CONTEXT_FLAG: &str = "Quake_Mode_Editor"; 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 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 NOTIFICATIONS_CONTEXT_FLAG: &str = "Notifications_Enabled"; 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"; pub const VIM_MODE_CONTEXT_FLAG: &str = "Vim_Mode_Enabled"; pub const VIM_UNNAMED_SYSTEM_CLIPBOARD: &str = "Vim_Unnamed_System_Clipboard"; pub const VIM_SHOW_STATUS_BAR: &str = "Vim_Show_Status_Bar"; pub const JUMP_TO_BOTTOM_OF_BLOCK_BUTTON_CONTEXT_FLAG: &str = "Jump_To_Bottom_Of_Block_Button_Enabled"; pub const RESPECT_SYSTEM_THEME_CONTEXT_FLAG: &str = "Respect_System_Theme"; pub const COMPLETIONS_OPEN_WHILE_TYPING_CONTEXT_FLAG: &str = "Completions_Open_While_Typing"; pub const COMMAND_CORRECTIONS_CONTEXT_FLAG: &str = "Command_Corrections"; pub const ERROR_UNDERLINING_FLAG: &str = "error_underlining"; pub const SYNTAX_HIGHLIGHTING_FLAG: &str = "syntax_highlighting"; pub const SAME_LINE_PROMPT: &str = "Same_Line_Prompt_Enabled"; pub const TELEMETRY_FLAG: &str = "telemetry"; 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 DIM_INACTIVE_PANES_FLAG: &str = "Dim_Inactive_Panes"; pub const QUIT_WARNING_MODAL: &str = "Quit_Warning_Modal"; pub const BLOCK_DIVIDERS_CONTEXT_FLAG: &str = "Block_Dividers_Enabled"; pub const LOG_OUT_WARNING_MODAL: &str = "Log_Out_Warning_Modal"; pub const SMART_SELECT_FLAG: &str = "Smart_Select_Enabled"; 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 USE_VERTICAL_TABS_FLAG: &str = "Use_Vertical_Tabs"; 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"; pub const ALIAS_EXPANSION_FLAG: &str = "Alias_Expansion_Enabled"; pub const MIDDLE_CLICK_PASTE_FLAG: &str = "Middle_Click_Paste_Enabled"; pub const CODE_AS_DEFAULT_EDITOR: &str = "Code_As_Default_Enabled"; pub const SYNC_ALL_TABS_FLAG: &str = "Sync_All_Tabs_Enabled"; pub const SYNC_ALL_PANES_IN_CURRENT_TAB: &str = "Sync_All_Panes_In_Current_Tab"; pub const USE_AUDIBLE_BELL_CONTEXT_FLAG: &str = "Use_Audible_Terminal_Bell"; pub const SHOW_INPUT_HINT_TEXT_CONTEXT_FLAG: &str = "Show_Input_Hint_text"; pub const SHOW_AGENT_TIPS_FLAG: &str = "Show_Agent_Tips"; pub const SHOW_OZ_UPDATES_IN_ZERO_STATE_FLAG: &str = "Show_Oz_Updates_In_Zero_State"; pub const USE_AGENT_FOOTER_FLAG: &str = "Use_Agent_Footer"; 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 SHOW_TERMINAL_INPUT_MESSAGE_LINE_FLAG: &str = "Show_Terminal_Input_Message_Line"; pub const SLASH_COMMANDS_IN_TERMINAL_FLAG: &str = "Slash_Commands_In_Terminal"; pub const AUTOSUGGESTIONS_ENABLED_FLAG: &str = "Autosuggestions_Enabled"; pub const AUTOSUGGESTION_KEYBINDING_HINT_FLAG: &str = "Hide_Autosuggestion_Keybinding_Hint"; 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"; pub const RECORDING_MODE_FLAG: &str = "Recording_Mode_Enabled"; pub const IN_BAND_GENERATORS_FLAG: &str = "In_Band_Generators_Enabled"; pub const WARP_SAME_LINE_PROMPT_FLAG: &str = "Warp_Same_Line_Prompt_Enabled"; pub const DEBUG_NETWORK_ONLINE_FLAG: &str = "Network_Status_Online"; pub const AI_INPUT_AUTODETECTION_FLAG: &str = "AI_Input_Autodetection"; pub const NLD_IN_TERMINAL_FLAG: &str = "NLD_In_Terminal"; pub const INTELLIGENT_AUTOSUGGESTIONS_FLAG: &str = "Intelligent_Autosuggestions"; pub const PROMPT_SUGGESTIONS_FLAG: &str = "Prompt_Suggestions"; 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 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"; pub const IS_ACTIVE_AI_ENABLED: &str = "IsActiveAIEnabled"; pub const IS_VOICE_INPUT_ENABLED: &str = "IsVoiceInputEnabled"; pub const IS_BLOCK_AI_SUMMARIES_ENABLED: &str = "IsBlockAISummariesEnabled"; pub const IS_CODEBASE_INDEXING_ENABLED: &str = "IsCodebaseIndexingEnabled"; pub const IS_AUTOINDEXING_ENABLED: &str = "IsAutoIndexingEnabled"; pub const LIGATURE_RENDERING_CONTEXT_FLAG: &str = "Ligature_Rendering_Enabled"; pub const HAS_SETTINGS_TO_IMPORT_FLAG: &str = "HasSettingsToImport"; /// The user's setting enabled UDI, but we may show a classic input (e.g. ssh/subshell warpification) pub const UNIVERSAL_DEVELOPER_INPUT_ENABLED: &str = "UniversalDeveloperInputEnabled"; pub const AGENT_MODE_INPUT: &str = "InputAgentMode"; pub const TERMINAL_MODE_INPUT: &str = "InputTerminalMode"; pub const WARP_IS_DEFAULT_TERMINAL: &str = "WarpIsDefaultTerminal"; pub const PASSIVE_CODE_DIFF_KEYBINDINGS_ENABLED: &str = "PassiveCodeDiffKeybindingsEnabled"; /// 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"; pub const HAS_PENDING_PROMPT_SUGGESTION: &str = "HasPendingPromptSuggestion"; pub const ACTIVE_AGENT_VIEW: &str = "ActiveAgentView"; pub const ACTIVE_INLINE_AGENT_VIEW: &str = "ActiveInlineAgentView"; /// When set, ctrl-enter should be the active binding to enter agent view. /// /// This is true on linux and windows. pub const CTRL_ENTER_ENTERS_AGENT_VIEW: &str = "CtrlEnterEntersAgentView"; pub const AGENT_VIEW_ENABLED: &str = "FeatureFlag.AgentView"; pub const LOCKED_INPUT: &str = "LockedInput"; pub const OPEN_INLINE_CONVERSATION_MENU: &str = "OpenInlineConversationMenu"; pub const EMPTY_INPUT_BUFFER: &str = "EmptyInputBuffer"; 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 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 fn init_actions_from_parent_view( app: &mut AppContext, context: &ContextPredicate, builder: fn(SettingsAction) -> T, ) { main_page::init_actions_from_parent_view(app, context, builder); appearance_page::init_actions_from_parent_view(app, context, builder); features_page::init_actions_from_parent_view(app, context, builder); warpify_page::init_actions_from_parent_view(app, context, builder); 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); if ChannelState::enable_debug_features() || cfg!(windows) { ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings( vec![ ToggleSettingActionPair::custom( SettingActionPairDescriptions::new( "Show initialization block", "Hide initialization block", ), builder(SettingsAction::Debug( DebugSettingsAction::ToggleInitializationBlock, )), SettingActionPairContexts::new( context.to_owned() & !id!(flags::INITIALIZATION_BLOCK_FLAG), context.to_owned() & id!(flags::INITIALIZATION_BLOCK_FLAG), ), None, ), ToggleSettingActionPair::custom( SettingActionPairDescriptions::new( "Show in-band command blocks", "Hide in-band command blocks", ), builder(SettingsAction::Debug( DebugSettingsAction::ToggleInBandCommandBlocks, )), SettingActionPairContexts::new( context.to_owned() & !id!(flags::IN_BAND_COMMAND_BLOCKS_FLAG), context.to_owned() & id!(flags::IN_BAND_COMMAND_BLOCKS_FLAG), ), None, ), ], app, ); } if FeatureFlag::DebugMode.is_enabled() { ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings( vec![ ToggleSettingActionPair::new( "recording mode", WorkspaceAction::ToggleRecordingMode, &id!("Workspace"), flags::RECORDING_MODE_FLAG, ), ToggleSettingActionPair::new( "in-band generators for new sessions", WorkspaceAction::ToggleInBandGenerators, &id!("Workspace"), flags::IN_BAND_GENERATORS_FLAG, ), ToggleSettingActionPair::new( "debug network status", WorkspaceAction::ToggleDebugNetworkStatus, &id!("Workspace"), flags::DEBUG_NETWORK_ONLINE_FLAG, ), ToggleSettingActionPair::new( "memory statistics", WorkspaceAction::ToggleShowMemoryStats, &id!("Workspace"), flags::DEBUG_SHOW_MEMORY_STATS_FLAG, ), ], app, ); } let context = id!("SettingsViewInTab") & !id!("IMEOpen"); app.register_fixed_bindings([ FixedBinding::new("down", SettingsAction::Down, context.clone()), FixedBinding::new("up", SettingsAction::Up, context.clone()), ]); } /// The string the user will see when the action is enabled or disabled. #[derive(Clone)] pub struct SettingActionPairDescriptions { enable: String, disable: String, } impl SettingActionPairDescriptions { pub fn new(enable: &str, disable: &str) -> Self { Self { enable: enable.to_owned(), disable: disable.to_owned(), } } } /// The context to check to show the enable or disable /// version of this action pair. #[derive(Clone)] pub struct SettingActionPairContexts { enable_predicate: ContextPredicate, disable_predicate: ContextPredicate, } impl SettingActionPairContexts { pub fn new(enable_predicate: ContextPredicate, disable_predicate: ContextPredicate) -> Self { Self { enable_predicate, disable_predicate, } } } /// Information needed to create a enable/disable action pair. /// Note: The action pair doesn't actually need to update settings. /// We should probably refactor this code to a different module. #[derive(Clone)] pub struct ToggleSettingActionPair { /// The user will actually read these strings. descriptions: SettingActionPairDescriptions, /// The actual action to toggle a setting on/off. toggle_action: T, /// We use our Context tree to determine where this setting should show up. /// Be sure to initialize all context strings you use /// in `fn keymap_context` in `impl View for Workspace`. contexts: SettingActionPairContexts, /// If Some(), custom_action is set as the Custom Trigger for the /// the toggle_action. /// This makes it possible to bind Mac menu items to the toggle_action. custom_action: Option, /// Binding group for the set of actions produced by this pair. If not explicitly set, the /// `Settings` [`BindingGroup`] is applied. binding_group: BindingGroup, /// Predicate that determines if bindings corresponding to this pair are enabled. enabled_predicate: Option, /// Whether or not this pairing applies to the current platform (Mac, Linux, Web, etc.) supported_on_current_platform: bool, } impl ToggleSettingActionPair { /// `description_suffix` will be visible to the user, /// e.g. `Enable {description_suffix}` or `Disable {description_suffix}`. /// We use contexts to decide if we show the user the enable or disable /// version of this action pair. /// `context_prefix` is logically ANDed with context_boolean_flag, /// like a prerequisite. /// `context_prefix` should be `Workspace` to have the action pair to /// display in the command palette. /// `context_boolean_flag` is will be in the context tree when the action /// is in the enabled state, /// and absent when the action is in the disabled state. pub fn new( description_suffix: &str, toggle_action: T, context_prefix: &ContextPredicate, context_boolean_flag: &'static str, ) -> Self { use warpui::keymap::macros::id; ToggleSettingActionPair { descriptions: SettingActionPairDescriptions { enable: format!("Enable {description_suffix}"), disable: format!("Disable {description_suffix}"), }, contexts: SettingActionPairContexts { enable_predicate: context_prefix.to_owned() & !id!(context_boolean_flag), disable_predicate: context_prefix.to_owned() & id!(context_boolean_flag), }, toggle_action, custom_action: None, binding_group: BindingGroup::Settings, supported_on_current_platform: true, enabled_predicate: None, } } pub fn custom( descriptions: SettingActionPairDescriptions, toggle_action: T, contexts: SettingActionPairContexts, custom_action: Option, ) -> Self { ToggleSettingActionPair { toggle_action, contexts, descriptions, custom_action, binding_group: BindingGroup::Settings, supported_on_current_platform: true, enabled_predicate: None, } } pub fn with_group(mut self, group: BindingGroup) -> Self { self.binding_group = group; self } pub fn with_enabled(mut self, enabled_predicate: EnabledPredicate) -> Self { self.enabled_predicate = Some(enabled_predicate); 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, } } /// Creates enable/disable bindings for a toggle feature, given a list of `ToggleSettingActionPair`'s. pub fn add_toggle_setting_action_pairs_as_bindings( action_pairs: Vec>, app: &mut AppContext, ) { let (enable_bindings, disable_bindings): (Vec, Vec) = action_pairs .into_iter() .filter_map(|action_pair| { let ToggleSettingActionPair { toggle_action, contexts, descriptions, custom_action, binding_group, supported_on_current_platform, enabled_predicate, } = action_pair; if !supported_on_current_platform { None } else { match custom_action { Some(custom_action) => { let mut enable_binding = FixedBinding::custom( custom_action, toggle_action.clone(), descriptions.enable, contexts.enable_predicate, ) .with_group(binding_group.as_str()); let mut disable_binding = FixedBinding::custom( custom_action, toggle_action, descriptions.disable, contexts.disable_predicate, ) .with_group(binding_group.as_str()); if let Some(enabled_predicate) = enabled_predicate { enable_binding = enable_binding.with_enabled(enabled_predicate); disable_binding = disable_binding.with_enabled(enabled_predicate); } Some((enable_binding, disable_binding)) } None => { let mut enable_binding = FixedBinding::empty( descriptions.enable, toggle_action.clone(), contexts.enable_predicate, ) .with_group(binding_group.as_str()); let mut disable_binding = FixedBinding::empty( descriptions.disable, toggle_action, contexts.disable_predicate, ) .with_group(binding_group.as_str()); if let Some(enabled_predicate) = enabled_predicate { enable_binding = enable_binding.with_enabled(enabled_predicate); disable_binding = disable_binding.with_enabled(enabled_predicate); } Some((enable_binding, disable_binding)) } } } }) .unzip(); app.register_fixed_bindings(enable_bindings); app.register_fixed_bindings(disable_bindings); } } #[derive(Clone, Debug)] pub enum DebugSettingsAction { /// Whether or not the "bootstrap block" or "initialization block" is visible. ToggleInitializationBlock, /// Whether or not in-band generator commands are visible in the BlockList. ToggleInBandCommandBlocks, } #[derive(Debug, Clone)] pub enum SettingsAction { SelectAndRefresh(SettingsSection), ToggleUmbrella(usize), MainPageToggle(MainPageAction), AppearancePageToggle(AppearancePageAction), FeaturesPageToggle(FeaturesPageAction), PrivacyPageToggle(PrivacyPageAction), AI(AISettingsPageAction), Code(CodeSettingsPageAction), WarpDrive(warp_drive_page::WarpDriveSettingsPageAction), WarpifyPageToggle(WarpifyPageAction), Tab, Split(Direction), ToggleMaximizePane, Close, OpenContextMenu(Vector2F), FocusSelf, Up, Down, /// For internal, debug-related settings which don't appear in the UI. Debug(DebugSettingsAction), } #[derive(Copy, Clone, Debug)] enum CycleDirection { Up, Down, } /// A stop in the arrow-key navigation order over the sidebar. /// /// A collapsed umbrella occupies a single stop rather than being skipped, /// so arrow-key navigation auto-expands it and selects one of its visible /// subpages instead of jumping over it. Which subpage is chosen depends /// on the direction of cycling: navigating Down enters the umbrella at /// its first visible subpage, while navigating Up enters at its last /// visible subpage, matching the natural reading order the user was /// moving through. #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum NavStop { /// A concrete page, or a subpage of an already-expanded umbrella. /// Arrow-key nav lands directly on this section. Section(SettingsSection), /// A collapsed umbrella. Activating this stop navigates to either /// `first_subpage` (when arriving from above via Down) or /// `last_subpage` (when arriving from below via Up), which /// auto-expands the umbrella via /// [`SettingsView::set_and_refresh_current_page_internal`]. CollapsedUmbrella { /// Index into `nav_items`. Used to detect when the currently active /// page belongs to this collapsed umbrella (e.g. the user manually /// collapsed it while on one of its subpages), so cycling still /// moves relative to the umbrella's position in the nav order. nav_index: usize, first_subpage: SettingsSection, last_subpage: SettingsSection, }, } /// Builds the ordered list of arrow-key nav stops from `nav_items`. /// /// `is_visible` decides which sections are currently shown in the sidebar; /// callers pass a predicate that ignores the search filter when no search /// is active and applies it otherwise. Umbrellas with no visible subpages /// are skipped entirely. fn build_nav_stops(nav_items: &[SettingsNavItem], is_visible: F) -> Vec where F: Fn(SettingsSection) -> bool, { nav_items .iter() .enumerate() .flat_map(|(nav_index, item)| match item { SettingsNavItem::Page(section) => { if is_visible(*section) { vec![NavStop::Section(*section)] } else { vec![] } } SettingsNavItem::Umbrella(umbrella) => { let visible: Vec = umbrella .subpages .iter() .copied() .filter(|s| is_visible(*s)) .collect(); if visible.is_empty() { vec![] } else if umbrella.expanded { visible.into_iter().map(NavStop::Section).collect() } else { let first_subpage = visible[0]; let last_subpage = *visible.last().unwrap_or(&first_subpage); vec![NavStop::CollapsedUmbrella { nav_index, first_subpage, last_subpage, }] } } }) .collect() } /// Returns the index in `stops` that corresponds to `section`. /// /// A collapsed-umbrella stop also matches when `section` is one of the /// umbrella's subpages — this covers the edge case where the user manually /// collapsed the umbrella while still on a subpage, so arrow-key cycling /// continues to move relative to the umbrella's position in the nav order. fn current_stop_index( stops: &[NavStop], nav_items: &[SettingsNavItem], section: SettingsSection, ) -> Option { stops.iter().position(|stop| match stop { NavStop::Section(s) => *s == section, NavStop::CollapsedUmbrella { nav_index, .. } => matches!( nav_items.get(*nav_index), Some(SettingsNavItem::Umbrella(u)) if u.contains(section) ), }) } /// Returns the next index after applying `direction`, wrapping around the /// ends of the list. Caller must ensure `len > 0`. fn next_stop_index(current: usize, len: usize, direction: CycleDirection) -> usize { debug_assert!(len > 0, "next_stop_index requires a non-empty stop list"); match direction { CycleDirection::Up => { if current == 0 { len - 1 } else { current - 1 } } CycleDirection::Down => { if current + 1 >= len { 0 } else { current + 1 } } } } macro_rules! update_page { ($handle:expr, $update:expr, $ctx:expr) => { match $handle { SettingsPageViewHandle::Main(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::SharedBlocks(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Teams(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::BillingAndUsage(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::MCPServers(handle) => $ctx.update_view(handle, $update), SettingsPageViewHandle::WarpDrive(handle) => $ctx.update_view(handle, $update), } }; } pub struct SettingsView { pane_configuration: ModelHandle, focus_handle: Option, settings_pages: Vec, pages_filter: Vec, current_settings_page: SettingsSection, search_editor: ViewHandle, clipped_scroll_state: ClippedScrollStateHandle, context_menu: ViewHandle>, context_menu_state: Option, environments_page_handle: ViewHandle, /// Sidebar navigation items (pages + umbrellas). nav_items: Vec, /// Handle to the AI settings page, used to switch subpage modes. ai_page_handle: ViewHandle, /// Handle to the Code settings page, used to switch subpage modes. code_page_handle: ViewHandle, /// Per-subpage search match results. Populated during search so that /// subpages sharing the same backing page can be filtered independently. subpage_filter: HashMap, /// Current settings.toml error, mirrored from `Workspace` via /// [`set_settings_error_state`]. Used by the sidebar footer to decide /// whether to show the inline error alert. settings_file_error: Option, /// Whether the workspace-level settings-error banner has been dismissed. /// Mirrored from `Workspace` via [`set_settings_error_state`]. settings_error_banner_dismissed: bool, /// Mouse state handles for the nav-rail footer buttons. Constructed once /// per `SettingsView` per `WARP.md`'s guidance that inline /// `MouseStateHandle::default()` breaks hover/click tracking. footer_mouse_states: SettingsFooterMouseStates, } impl SettingsView { pub fn new(page: Option, ctx: &mut ViewContext) -> Self { let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("Settings")); let global_resource_handles = GlobalResourceHandlesProvider::as_ref(ctx).get().clone(); // Main settings page with accounts info let main_page_handle = ctx.add_typed_action_view(MainSettingsPageView::new); ctx.subscribe_to_view(&main_page_handle, |me, _, event, ctx| { me.handle_main_page_event(event, ctx); }); // Appearance & themes page let appearance_page_handle = ctx.add_typed_action_view(AppearanceSettingsPageView::new); ctx.subscribe_to_view(&appearance_page_handle, |me, _, event, ctx| { me.handle_appearance_page_event(event, ctx); }); // Features page let features_page_handle = ctx.add_typed_action_view(|ctx| { FeaturesPageView::new(global_resource_handles.clone(), ctx) }); ctx.subscribe_to_view(&features_page_handle, |me, _, event, ctx| { me.handle_features_page_event(event, ctx); }); // Shared blocks page let block_client = ServerApiProvider::as_ref(ctx).get_block_client(); let show_blocks_view_handle = ctx.add_typed_action_view(|ctx| ShowBlocksView::new(block_client, ctx)); ctx.subscribe_to_view(&show_blocks_view_handle, |_, _, event, ctx| match event { ShowBlocksEvent::ShowToast { message, flavor } => { ctx.emit(SettingsViewEvent::ShowToast { message: message.clone(), flavor: *flavor, }) } }); // About page let about_page_handle = ctx.add_view(AboutPageView::new); // AI page let ai_page_handle = ctx.add_typed_action_view(AISettingsPageView::new); let ai_page_handle_for_nav = ai_page_handle.clone(); ctx.subscribe_to_view(&ai_page_handle, |me, _, event, ctx| { me.handle_ai_page_event(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 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| { me.handle_billing_and_usage_page_event(event, ctx); }); // Keybindings page let keybindings_handle = ctx.add_typed_action_view(KeybindingsView::new); // Code page let code_page_handle = ctx.add_typed_action_view(CodeSettingsPageView::new); let code_page_handle_for_nav = code_page_handle.clone(); ctx.subscribe_to_view(&code_page_handle, |me, _, event, ctx| { me.handle_code_page_event(event, ctx); }); // Teams page, adding unconditionally, as `should_render` later on decides whether it // should be shown to the user or not let teams_page_handle = ctx.add_typed_action_view(TeamsPageView::new); ctx.subscribe_to_view(&teams_page_handle, |_, _, event, ctx| match event { TeamsPageViewEvent::TeamsChanged => ctx.notify(), TeamsPageViewEvent::OpenWarpDrive => ctx.emit(SettingsViewEvent::OpenWarpDrive), TeamsPageViewEvent::ShowToast { message, flavor } => { ctx.emit(SettingsViewEvent::ShowToast { message: message.clone(), flavor: *flavor, }) } }); let warpify_page_handle = ctx.add_typed_action_view(WarpifyPageView::new); ctx.subscribe_to_view(&warpify_page_handle, |me, _, event, ctx| { me.handle_warpify_page_event(event, ctx); }); // Render the privacy page only if telemetry opt-out is enabled. let privacy_page_handle = ctx.add_typed_action_view(PrivacyPageView::new); ctx.subscribe_to_view(&privacy_page_handle, |me, _, event, ctx| { 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); }); // Warp Drive page let warp_drive_page_handle = ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new); ctx.subscribe_to_view(&warp_drive_page_handle, |me, _, event, ctx| { me.handle_warp_drive_page_event(event, ctx); }); let platform_page_handle = ctx.add_typed_action_view(platform_page::PlatformPageView::new); ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| { me.handle_platform_page_event(event, ctx); }); // MCP Servers page let mcp_servers_page_handle = ctx.add_typed_action_view(MCPServersSettingsPageView::new); ctx.subscribe_to_view(&mcp_servers_page_handle, |me, _, event, ctx| { me.handle_mcp_servers_page_event(event, ctx); }); let font_family = Appearance::as_ref(ctx).ui_font_family(); let search_editor = ctx.add_typed_action_view(|ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_family_override: Some(font_family), ..Default::default() }, // We want "up" and "down" to cycle settings pages. propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("Search", ctx); editor }); ctx.subscribe_to_view(&search_editor, Self::handle_search_editor_event); let context_menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&context_menu, move |me, _, event, ctx| { me.handle_menu_event(event, ctx); }); let mut settings_pages = vec![ SettingsPage::new(main_page_handle), SettingsPage::new(ai_page_handle), SettingsPage::new(billing_and_usage_page_handle), SettingsPage::new(code_page_handle), SettingsPage::new(teams_page_handle), SettingsPage::new(appearance_page_handle), SettingsPage::new(features_page_handle), SettingsPage::new(keybindings_handle), SettingsPage::new(platform_page_handle), SettingsPage::new(warpify_page_handle), SettingsPage::new(referrals_page_handle), SettingsPage::new(show_blocks_view_handle), SettingsPage::new(warp_drive_page_handle), ]; settings_pages.extend(vec![ SettingsPage::new(mcp_servers_page_handle), SettingsPage::new(environments_page_handle.clone()), SettingsPage::new(privacy_page_handle), SettingsPage::new(about_page_handle), ]); // Build sidebar nav items. AI page is presented as an "Agents" umbrella // with subpages; the actual AI SettingsPage is hidden from direct sidebar listing. let mut nav_items = vec![ SettingsNavItem::Page(SettingsSection::Account), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Agents", SettingsSection::ai_subpages().to_vec(), )), SettingsNavItem::Page(SettingsSection::BillingAndUsage), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Code", vec![ SettingsSection::CodeIndexing, SettingsSection::EditorAndCodeReview, ], )), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Cloud platform", vec![ SettingsSection::CloudEnvironments, SettingsSection::OzCloudAPIKeys, ], )), SettingsNavItem::Page(SettingsSection::Teams), SettingsNavItem::Page(SettingsSection::Appearance), SettingsNavItem::Page(SettingsSection::Features), SettingsNavItem::Page(SettingsSection::Keybindings), SettingsNavItem::Page(SettingsSection::Warpify), SettingsNavItem::Page(SettingsSection::Referrals), SettingsNavItem::Page(SettingsSection::SharedBlocks), SettingsNavItem::Page(SettingsSection::WarpDrive), SettingsNavItem::Page(SettingsSection::Privacy), SettingsNavItem::Page(SettingsSection::About), ]; // 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(section) if section.is_subpage() => section, other => other.unwrap_or_default(), }; // Auto-expand the umbrella if the initial page is one of its subpages. if initial_page.is_subpage() { for item in &mut nav_items { if let SettingsNavItem::Umbrella(umbrella) = item { if umbrella.contains(initial_page) { umbrella.expanded = true; } } } } Self { pages_filter: settings_pages .iter() .map(|_| MatchData::Uncounted(true)) .collect(), settings_pages, current_settings_page: initial_page, pane_configuration, focus_handle: None, search_editor, clipped_scroll_state: Default::default(), context_menu, context_menu_state: Default::default(), environments_page_handle, nav_items, ai_page_handle: ai_page_handle_for_nav, code_page_handle: code_page_handle_for_nav, subpage_filter: HashMap::new(), settings_file_error: None, settings_error_banner_dismissed: false, footer_mouse_states: SettingsFooterMouseStates::default(), } } /// Pushes the current settings-file error state from `Workspace` into this /// view. Called by `Workspace` once at construction time and then again /// whenever the error state or banner dismissal changes. Triggers a /// re-render when anything actually changed. pub fn set_settings_error_state( &mut self, error: Option, banner_dismissed: bool, ctx: &mut ViewContext, ) { let error_changed = self.settings_file_error != error; let dismissed_changed = self.settings_error_banner_dismissed != banner_dismissed; if !error_changed && !dismissed_changed { return; } self.settings_file_error = error; self.settings_error_banner_dismissed = banner_dismissed; ctx.notify(); } pub fn pane_configuration(&self) -> ModelHandle { self.pane_configuration.clone() } pub fn focus(&mut self, ctx: &mut ViewContext) { ctx.focus(&self.search_editor); ctx.emit(SettingsViewEvent::Pane(PaneEvent::FocusSelf)); } fn filtered_pages<'a>( &'a self, app: &'a AppContext, ) -> impl Iterator { self.settings_pages .iter() .zip(self.pages_filter.iter()) .filter_map(move |(page, match_data)| { (self.should_render_page(page, app) && match_data.is_truthy()) .then_some((page, *match_data)) }) } fn handle_search_editor_event( &mut self, editor: ViewHandle, event: &EditorEvent, ctx: &mut ViewContext, ) { match event { EditorEvent::Edited(_) => { let search_query = editor.as_ref(ctx).buffer_text(ctx); let is_search_active = !search_query.is_empty(); if is_search_active { // Save umbrella expanded state before search modifies it. for item in &mut self.nav_items { if let SettingsNavItem::Umbrella(umbrella) = item { if umbrella.pre_search_expanded.is_none() { umbrella.pre_search_expanded = Some(umbrella.expanded); } } } // Run per-subpage filtering for pages with multiple subpages. // For each AI subpage, temporarily switch to that subpage's // widget set and run the filter to get a subpage-specific result. self.subpage_filter.clear(); for &subpage_section in SettingsSection::ai_subpages() { if subpage_section == SettingsSection::AgentMCPServers { // AgentMCPServers has its own backing page; handled below. continue; } if let Some(subpage) = AISubpage::from_section(subpage_section) { self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(Some(subpage), ctx); }); let match_data = self .ai_page_handle .update(ctx, |view, ctx| view.update_filter(&search_query, ctx)); self.subpage_filter.insert(subpage_section, match_data); } } // Do the same for Code subpages. for &subpage_section in SettingsSection::code_subpages() { if let Some(subpage) = CodeSubpage::from_section(subpage_section) { self.code_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(Some(subpage), ctx); }); let match_data = self .code_page_handle .update(ctx, |view, ctx| view.update_filter(&search_query, ctx)); self.subpage_filter.insert(subpage_section, match_data); } } } else { // Search cleared: restore umbrella expanded state. for item in &mut self.nav_items { if let SettingsNavItem::Umbrella(umbrella) = item { if let Some(saved) = umbrella.pre_search_expanded.take() { umbrella.expanded = saved; } } } self.subpage_filter.clear(); } // Run the standard page-level filter (needed for non-subpage pages // and for subpages with their own backing page like AgentMCPServers). // Switch AI/Code to all-widgets mode so standalone backing page // filter is correct for pages_filter. if is_search_active { self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(None, ctx); }); self.code_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(None, ctx); }); } for (i, page) in self.settings_pages.iter().enumerate() { self.pages_filter[i] = update_page!( &page.view_handle, |view, ctx| { let match_data = view.update_filter(&search_query, ctx); ctx.notify(); match_data }, ctx ); } // Restore the active subpage after filtering. if is_search_active { let current = self.current_settings_page; if current.is_ai_subpage() && current != SettingsSection::AgentMCPServers { if let Some(subpage) = AISubpage::from_section(current) { self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(Some(subpage), ctx); }); } } if current.is_code_subpage() { if let Some(subpage) = CodeSubpage::from_section(current) { self.code_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(Some(subpage), ctx); }); } } } // Auto-expand umbrellas that have matching subpages during search. if is_search_active { for item in &mut self.nav_items { if let SettingsNavItem::Umbrella(umbrella) = item { let has_match = umbrella.subpages.iter().any(|subpage_section| { self.subpage_filter .get(subpage_section) .map(|md| md.is_truthy()) .unwrap_or_else(|| { // Subpages with their own backing page // (e.g. AgentMCPServers, CloudEnvironments) // fall back to pages_filter. let backing = subpage_section.parent_page_section(); self.settings_pages .iter() .zip(self.pages_filter.iter()) .any(|(p, md)| p.section == backing && md.is_truthy()) }) }); if has_match { umbrella.expanded = true; } } } } // Auto-select: if the current subpage/page is no longer visible, // jump to the first visible subpage or page. let current_still_visible = if is_search_active { // For subpages with per-subpage filter, check the subpage itself. if let Some(md) = self.subpage_filter.get(&self.current_settings_page) { md.is_truthy() } else { // Fall back to backing page filter. let current_backing = self.current_settings_page.parent_page_section(); self.filtered_pages(ctx) .any(|(page, _)| page.section == current_backing) } } else { let current_backing = self.current_settings_page.parent_page_section(); self.filtered_pages(ctx) .any(|(page, _)| page.section == current_backing) }; if !current_still_visible { // Find the first visible section: check subpages first, then pages. let first_visible = if is_search_active { self.nav_items .iter() .flat_map(|item| match item { SettingsNavItem::Page(section) => vec![*section], SettingsNavItem::Umbrella(umbrella) => umbrella.subpages.clone(), }) .find(|section| { if let Some(md) = self.subpage_filter.get(section) { md.is_truthy() } else { let backing = section.parent_page_section(); self.settings_pages .iter() .zip(self.pages_filter.iter()) .any(|(p, md)| p.section == backing && md.is_truthy()) } }) } else { self.filtered_pages(ctx) .next() .map(|(page, _)| page.section) }; if let Some(new_section) = first_visible { self.set_and_refresh_current_page_internal( new_section, false, /* should_clear_query */ false, /* allow_steal_focus */ ctx, ); } } ctx.notify(); } EditorEvent::Navigate(NavigationKey::Down) => self.key_down(ctx), EditorEvent::Navigate(NavigationKey::Up) => self.key_up(ctx), EditorEvent::Escape => ctx.focus_self(), _ => {} } } fn context_menu_items(&self, ctx: &mut ViewContext) -> Vec> { let mut items = vec![]; if ContextFlag::CreateNewSession.is_enabled() { items.extend(vec![ MenuItemFields::new("Split pane right") .with_on_select_action(SettingsAction::Split(Direction::Right)) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_right", ctx, )) .into_item(), MenuItemFields::new("Split pane left") .with_on_select_action(SettingsAction::Split(Direction::Left)) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_left", ctx, )) .into_item(), MenuItemFields::new("Split pane down") .with_on_select_action(SettingsAction::Split(Direction::Down)) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_down", ctx, )) .into_item(), MenuItemFields::new("Split pane up") .with_on_select_action(SettingsAction::Split(Direction::Up)) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_up", ctx, )) .into_item(), ]); } let split_pane_state = self .focus_handle .as_ref() .map(|h| h.split_pane_state(ctx)) .unwrap_or(SplitPaneState::NotInSplitPane); if split_pane_state.is_in_split_pane() { let is_maximized = split_pane_state.is_maximized(); items.push( MenuItemFields::toggle_pane_action(is_maximized) .with_on_select_action(SettingsAction::ToggleMaximizePane) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:toggle_maximize_pane", ctx, )) .into_item(), ); items.push( MenuItemFields::new("Close pane") .with_on_select_action(SettingsAction::Close) .with_key_shortcut_label( custom_tag_to_keystroke(CustomAction::CloseCurrentSession.into()) .map(|keystroke| keystroke.displayed()), ) .into_item(), ); } items } fn handle_menu_event(&mut self, event: &menu::Event, ctx: &mut ViewContext) { if let menu::Event::Close { .. } = event { self.context_menu_state.take(); } ctx.notify(); } fn clear_search_query(&mut self, ctx: &mut ViewContext) { self.search_editor.update(ctx, |editor, ctx| { editor.clear_buffer(ctx); }); self.pages_filter = self .settings_pages .iter() .map(|_| MatchData::Uncounted(true)) .collect(); } fn handle_main_page_event( &mut self, event: &MainSettingsPageEvent, ctx: &mut ViewContext, ) { match event { MainSettingsPageEvent::CheckForUpdate => ctx.emit(SettingsViewEvent::CheckForUpdate), MainSettingsPageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } _ => (), } } fn handle_billing_and_usage_page_event( &mut self, event: &BillingAndUsagePageEvent, ctx: &mut ViewContext, ) { match event { BillingAndUsagePageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } BillingAndUsagePageEvent::ShowToast { message, flavor } => { ctx.emit(SettingsViewEvent::ShowToast { message: message.clone(), flavor: *flavor, }) } BillingAndUsagePageEvent::ShowModal => ctx.notify(), BillingAndUsagePageEvent::HideModal => ctx.notify(), } } fn handle_appearance_page_event( &mut self, event: &SettingsPageEvent, ctx: &mut ViewContext, ) { match event { SettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), SettingsPageEvent::Pane(_) | SettingsPageEvent::EnvironmentSetupModeSelectorToggled { .. } | SettingsPageEvent::AgentAssistedEnvironmentModalToggled { .. } => { // These events are not handled in standalone settings - only used // when the view is hosted inside a pane. } } } fn handle_environments_page_event( &mut self, event: &SettingsPageEvent, ctx: &mut ViewContext, ) { match event { SettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), SettingsPageEvent::EnvironmentSetupModeSelectorToggled { .. } | SettingsPageEvent::AgentAssistedEnvironmentModalToggled { .. } => { // Re-render so the modal overlay is shown/hidden. ctx.notify(); } SettingsPageEvent::Pane(_) => { // Not applicable in standalone settings view. } } } fn handle_features_page_event( &mut self, event: &FeaturesSettingsPageEvent, ctx: &mut ViewContext, ) { match event { FeaturesSettingsPageEvent::SearchForKeybinding(query) => { self.search_for_keybinding(query, ctx); } FeaturesSettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), } } fn handle_warpify_page_event( &mut self, event: &SettingsPageEvent, ctx: &mut ViewContext, ) { match event { SettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), SettingsPageEvent::Pane(_) | SettingsPageEvent::EnvironmentSetupModeSelectorToggled { .. } | SettingsPageEvent::AgentAssistedEnvironmentModalToggled { .. } => { // These events are not handled in standalone settings - only used // when the view is hosted inside a pane. } } } fn handle_privacy_page_event( &mut self, event: &PrivacyPageViewEvent, ctx: &mut ViewContext, ) { match event { PrivacyPageViewEvent::LaunchNetworkLogging => { ctx.emit(SettingsViewEvent::LaunchNetworkLogging); } PrivacyPageViewEvent::ShowAddRegexModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } PrivacyPageViewEvent::HideAddRegexModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } } } fn handle_platform_page_event( &mut self, event: &platform_page::PlatformPageViewEvent, ctx: &mut ViewContext, ) { match event { platform_page::PlatformPageViewEvent::ShowCreateApiKeyModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } platform_page::PlatformPageViewEvent::HideCreateApiKeyModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } } } fn handle_mcp_servers_page_event( &mut self, event: &MCPServersSettingsPageEvent, ctx: &mut ViewContext, ) { match event { MCPServersSettingsPageEvent::ShowModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } MCPServersSettingsPageEvent::HideModal => { // Modal rendering is handled in get_modal_content_for_page ctx.notify(); } } } pub fn search_for_keybinding(&mut self, keybinding_name: &str, ctx: &mut ViewContext) { self.set_and_refresh_current_page(SettingsSection::Keybindings, ctx); if let Some(settings_page) = self.current_settings_page() { if let SettingsPageViewHandle::Keybindings(view_handle) = &settings_page.view_handle { view_handle.update(ctx, |view, ctx| { view.search_for_binding(keybinding_name, ctx); }) } } } fn handle_referrals_page_event( &mut self, event: &ReferralsPageEvent, ctx: &mut ViewContext, ) { match event { ReferralsPageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } ReferralsPageEvent::FocusModal => ctx.focus(&self.search_editor), ReferralsPageEvent::ShowToast { message, flavor } => { ctx.emit(SettingsViewEvent::ShowToast { message: message.clone(), flavor: *flavor, }) } } } fn handle_warp_drive_page_event( &mut self, event: &warp_drive_page::WarpDriveSettingsPageEvent, ctx: &mut ViewContext, ) { match event { warp_drive_page::WarpDriveSettingsPageEvent::SignUp => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } } } fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext) { match event { AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), AISettingsPageEvent::OpenAIFactCollection => { ctx.emit(SettingsViewEvent::OpenAIFactCollection) } AISettingsPageEvent::OpenMCPServerCollection => { ctx.emit(SettingsViewEvent::OpenMCPServerCollection) } AISettingsPageEvent::OpenExecutionProfileEditor(profile_id) => { ctx.emit(SettingsViewEvent::OpenExecutionProfileEditor(*profile_id)); } AISettingsPageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } } } fn handle_code_page_event( &mut self, event: &CodeSettingsPageEvent, ctx: &mut ViewContext, ) { match event { CodeSettingsPageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } CodeSettingsPageEvent::OpenLspLogs { log_path } => { ctx.emit(SettingsViewEvent::OpenLspLogs { log_path: log_path.clone(), }); } CodeSettingsPageEvent::OpenProjectRules { rule_paths } => { ctx.emit(SettingsViewEvent::OpenProjectRulesPane { rule_paths: rule_paths.clone(), }); } } } pub fn current_settings_section(&self) -> SettingsSection { self.current_settings_page } fn current_settings_page(&self) -> Option<&SettingsPage> { // For AI subpages, the backing SettingsPage has section == AI. let lookup_section = self.current_settings_page.parent_page_section(); self.settings_pages .iter() .find(|page| page.section == lookup_section) } fn settings_page(&self, section: SettingsSection) -> Option<&SettingsPage> { let settings_page = self .settings_pages .iter() .find(|page| page.section == section); if settings_page.is_none() { log::warn!("settings section {section:?} not found"); } settings_page } pub fn set_and_refresh_current_page_internal( &mut self, section: SettingsSection, should_clear_query: bool, allow_steal_focus: bool, ctx: &mut ViewContext, ) { // Map internal backing-page sections to their default subpage. // External callers should use subpage variants directly. let section = match section { SettingsSection::AI => SettingsSection::WarpAgent, SettingsSection::Code => SettingsSection::CodeIndexing, other => other, }; // For AI subpages, the backing page is the AI page. Check it exists. let page_section = section.parent_page_section(); if self.settings_page(page_section).is_none() { return; } let previous_section = self.current_settings_page; ctx.enable_key_bindings_dispatching(); if let Some(current_page) = self.current_settings_page() { update_page!( ¤t_page.view_handle, |view, ctx| { view.clear_highlighted_widget(); ctx.notify(); }, ctx ); } if should_clear_query { self.clear_search_query(ctx); } self.current_settings_page = section; if previous_section != section && section == SettingsSection::CloudEnvironments { send_telemetry_from_ctx!(SettingsTelemetryEvent::EnvironmentsPageOpened, ctx); } // When navigating to a subpage, update the backing page's active subpage mode // and auto-expand the umbrella containing it. if section.is_subpage() { // AI subpages: update the AI page's subpage mode. if section.is_ai_subpage() && section != SettingsSection::AgentMCPServers { let subpage = AISubpage::from_section(section); self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(subpage, ctx); }); } // Code subpages: update the Code page's subpage mode. if section.is_code_subpage() { let subpage = CodeSubpage::from_section(section); self.code_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(subpage, ctx); }); } // Cloud platform subpages render their backing pages directly // (no subpage mode switch needed — the full page is shown). // Auto-expand the umbrella containing this subpage. for item in &mut self.nav_items { if let SettingsNavItem::Umbrella(umbrella) = item { if umbrella.contains(section) { umbrella.expanded = true; } } } } #[cfg(feature = "crash_reporting")] { crate::crash_reporting::set_tag("warp.settings_page", section.to_string()); } if let Some(settings_page) = self.current_settings_page() { update_page!( &settings_page.view_handle, |view, ctx| { view.on_page_selected(allow_steal_focus, ctx); }, ctx ); } ctx.notify(); } pub fn set_and_refresh_current_page( &mut self, section: SettingsSection, ctx: &mut ViewContext, ) { self.set_and_refresh_current_page_internal(section, true, true, ctx); } pub fn set_search_query(&mut self, query: &str, ctx: &mut ViewContext) { self.search_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(query, ctx); }); } fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool { match &settings_page.view_handle { SettingsPageViewHandle::Main(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Teams(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::SharedBlocks(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Keybindings(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Features(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Appearance(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::BillingAndUsage(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::About(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::OzCloudAPIKeys(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::AI(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::CloudEnvironments(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app), SettingsPageViewHandle::WarpDrive(v) => v.as_ref(app).should_render(app), } } /// Open the invite section of the teams page, optionally with an email to invite. pub fn open_teams_page_email_invite( &mut self, email: Option<&String>, ctx: &mut ViewContext, ) { if let Some(team_page) = self.settings_page(SettingsSection::Teams) { if let SettingsPageViewHandle::Teams(view) = &team_page.view_handle { view.update(ctx, |view, ctx| { view.open_team_members(email, ctx); }) } } } /// Open the MCP servers page, optionally to list page or edit page. /// If `autoinstall_gallery_title` is provided, triggers auto-install of the specified gallery MCP. pub fn open_mcp_servers_page( &mut self, page: MCPServersSettingsPage, autoinstall_gallery_title: Option<&str>, ctx: &mut ViewContext, ) { // Navigate to the AgentMCPServers subpage (under the Agents umbrella). self.set_and_refresh_current_page(SettingsSection::AgentMCPServers, ctx); if let Some(mcp_page) = self.settings_page(SettingsSection::MCPServers) { if let SettingsPageViewHandle::MCPServers(view) = &mcp_page.view_handle { view.update(ctx, |view, ctx| { view.update_page(page, ctx); if let Some(title) = autoinstall_gallery_title { view.autoinstall_from_gallery(title, ctx); } }) } } } /// Updates the PS1 prompt that is shown on the Appearance page. pub fn set_ps1_info( &mut self, ps1_grid_info: Option<(BlockGrid, SizeInfo)>, ctx: &mut ViewContext, ) { if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) { if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle { view.update(ctx, |view, ctx| { view.set_ps1_info(ps1_grid_info, ctx); }) } } } pub fn get_ps1_info(&self, app: &AppContext) -> Option<(BlockGrid, SizeInfo)> { self.settings_page(SettingsSection::Appearance) .and_then(|appearance_page| { if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle { view.read(app, |view, _| view.get_ps1_info().map(ToOwned::to_owned)) } else { None } }) } pub fn refresh_preferred_graphics_backend_dropdown(&mut self, ctx: &mut ViewContext) { if let Some(features_page) = self.settings_page(SettingsSection::Features) { if let SettingsPageViewHandle::Features(view) = &features_page.view_handle { view.update(ctx, |view, ctx| { view.refresh_preferred_graphics_backend_dropdown(ctx); }); } } } fn key_up(&mut self, ctx: &mut ViewContext) { self.cycle_pages(CycleDirection::Up, ctx) } fn key_down(&mut self, ctx: &mut ViewContext) { self.cycle_pages(CycleDirection::Down, ctx) } /// Predicate for whether `section` is currently visible in the sidebar /// under the active search filter. Mirrors the inline filtering used /// when rendering sidebar items so arrow-key navigation stays in sync /// with what the user can actually see. fn section_passes_search_filter(&self, section: SettingsSection) -> bool { if let Some(md) = self.subpage_filter.get(§ion) { md.is_truthy() } else { let backing = section.parent_page_section(); self.settings_pages .iter() .zip(self.pages_filter.iter()) .any(|(p, md)| p.section == backing && md.is_truthy()) } } fn cycle_pages(&mut self, direction: CycleDirection, ctx: &mut ViewContext) { let is_search_active = !self.search_editor.as_ref(ctx).buffer_text(ctx).is_empty(); // Build nav stops from the current sidebar state. A collapsed umbrella // is represented as a single stop (rather than being skipped) so that // arrow-key navigation auto-expands it and selects its first visible // subpage instead of silently jumping over it. let stops = build_nav_stops(&self.nav_items, |section| { !is_search_active || self.section_passes_search_filter(section) }); if stops.is_empty() { return; } let next_index = match current_stop_index(&stops, &self.nav_items, self.current_settings_page) { Some(idx) => next_stop_index(idx, stops.len(), direction), // Current page isn't in the visible nav order (e.g. it was // just filtered out); jump to the first visible stop. None => 0, }; // Selecting a subpage auto-expands its umbrella in // set_and_refresh_current_page_internal, which is exactly the behavior // we want when landing on a `CollapsedUmbrella` stop. We pick the // entry subpage based on `direction` so that Up into a collapsed // umbrella lands on its last visible subpage (matching the reading // order the user was moving through) and Down lands on the first. let target_section = match stops[next_index] { NavStop::Section(section) => section, NavStop::CollapsedUmbrella { first_subpage, last_subpage, .. } => match direction { CycleDirection::Up => last_subpage, CycleDirection::Down => first_subpage, }, }; self.set_and_refresh_current_page_internal(target_section, false, false, ctx); } fn input_tab(&mut self, ctx: &mut ViewContext) { if let Some(current_page) = self.current_settings_page() { match ¤t_page.view_handle { SettingsPageViewHandle::Keybindings(view_handle) => { view_handle.update(ctx, |view, ctx| view.on_tab_pressed(ctx)); } SettingsPageViewHandle::Teams(view_handle) => { view_handle.update(ctx, |view, ctx| view.on_tab_pressed(ctx)); } _ => (), }; } } pub fn scroll_to_settings_widget( &mut self, page: SettingsSection, widget_id: &'static str, ctx: &mut ViewContext, ) { self.set_and_refresh_current_page_internal(page, true, true, ctx); if let Some(current_page) = self.current_settings_page() { update_page!( ¤t_page.view_handle, |view, _| { view.scroll_to_widget(widget_id); }, ctx ) } } fn debug_settings_action(&mut self, action: &DebugSettingsAction, ctx: &mut ViewContext) { match action { DebugSettingsAction::ToggleInitializationBlock => { BlockVisibilitySettings::handle(ctx).update( ctx, |block_visibility_settings, ctx| { let _ = block_visibility_settings .should_show_bootstrap_block .toggle_and_save_value(ctx); }, ); } DebugSettingsAction::ToggleInBandCommandBlocks => { BlockVisibilitySettings::handle(ctx).update( ctx, |block_visibility_settings, ctx| { let _ = block_visibility_settings .should_show_in_band_command_blocks .toggle_and_save_value(ctx); }, ); } } } fn get_modal_content_for_page( &self, page_handle: &SettingsPageViewHandle, app: &AppContext, ) -> Option> { match page_handle { SettingsPageViewHandle::BillingAndUsage(view) => { view.read(app, |view, _| view.get_modal_content()) } SettingsPageViewHandle::Privacy(view) => { view.read(app, |view, _| view.get_modal_content()) } SettingsPageViewHandle::OzCloudAPIKeys(view) => { view.read(app, |view, _| view.get_modal_content()) } SettingsPageViewHandle::MCPServers(view) => { view.read(app, |view, _| view.get_modal_content(app)) } _ => None, } } fn render_search_editor(&self, appearance: &Appearance) -> Box { Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( icons::Icon::SearchSmall .to_warpui_icon(appearance.theme().active_ui_text_color()) .finish(), ) .with_width(16.) .with_height(16.) .finish(), ) .with_uniform_margin(4.) .with_margin_right(12.) .finish(), ) .with_child( Shrinkable::new( 1., Clipped::new(ChildView::new(&self.search_editor).finish()).finish(), ) .finish(), ) .finish(), ) .with_margin_left(16.) .with_margin_right(16.) .with_margin_bottom(8.) .finish() } fn render_search_zero_state(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); Container::new( Align::new( Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ Text::new( "No settings match your search.", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_style(Properties::default().weight(Weight::Medium)) .with_color(theme.sub_text_color(theme.background()).into_solid()) .finish(), Text::new( "You may want to try using different keywords or checking for any possible typos.", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.disabled_ui_text_color().into_solid()) .finish(), ]) .finish(), ) .finish(), ) .with_uniform_margin(16.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_background(internal_colors::fg_overlay_1(appearance.theme())) .finish() } } impl Entity for SettingsView { type Event = SettingsViewEvent; } impl View for SettingsView { fn ui_name() -> &'static str { "SettingsViewInTab" } fn render(&self, app: &AppContext) -> Box { let settings_pages = self.filtered_pages(app).collect_vec(); let appearance = Appearance::as_ref(app); // For AI subpages, the backing SettingsPage has a different section // (e.g. Oz -> AI, AgentMCPServers -> MCPServers). let content_page_section = self.current_settings_page.parent_page_section(); let (page, current_page_handle) = if settings_pages.is_empty() { (self.render_search_zero_state(appearance), None) } else { match settings_pages .iter() .find(|(page, _)| page.section == content_page_section) { None => (Empty::new().finish(), None), Some((page, _)) => (page.view_handle.child_view(), Some(&page.view_handle)), } }; let theme = appearance.theme(); let mut buttons = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(self.render_search_editor(appearance)); // Render sidebar using nav_items (pages + umbrellas). for (nav_index, nav_item) in self.nav_items.iter().enumerate() { match nav_item { SettingsNavItem::Page(section) => { let section = *section; // Find the page in settings_pages for render/visibility check. if let Some((page, match_data)) = settings_pages.iter().find(|(p, _)| p.section == section) { let page_active = section == self.current_settings_page; buttons.add_child( page.render_page_button(appearance, *match_data, page_active) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(SettingsAction::SelectAndRefresh( section, )); }) .finish(), ); } } SettingsNavItem::Umbrella(umbrella) => { // Check which subpages are visible. Use per-subpage filter // if available (search active), otherwise fall back to backing page. let is_subpage_visible = |section: &SettingsSection| -> bool { if let Some(md) = self.subpage_filter.get(section) { return md.is_truthy(); } let backing = section.parent_page_section(); settings_pages.iter().any(|(p, _)| p.section == backing) }; let any_subpage_visible = umbrella.subpages.iter().any(is_subpage_visible); // Only show the umbrella if at least one subpage is visible. if !any_subpage_visible { continue; } // Render umbrella header row. The whole row is a single // Hoverable so hover styling + pointing-hand cursor apply // across the full clickable area, not just the text. buttons.add_child( umbrella .render_umbrella_row(appearance) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(SettingsAction::ToggleUmbrella( nav_index, )); }) .finish(), ); // Render subpage items when expanded. if umbrella.expanded { for (sub_idx, subpage_section) in umbrella.subpages.iter().enumerate() { let subpage_section = *subpage_section; // Use per-subpage filter if available, otherwise backing page. let match_data = self .subpage_filter .get(&subpage_section) .copied() .unwrap_or_else(|| { let backing = subpage_section.parent_page_section(); settings_pages .iter() .find(|(p, _)| p.section == backing) .map(|(_, md)| *md) .unwrap_or(MatchData::Uncounted(false)) }); if !match_data.is_truthy() { continue; } let is_active = subpage_section == self.current_settings_page; if let Some(hoverable) = umbrella .render_subpage_button(sub_idx, appearance, match_data, is_active) { buttons.add_child( hoverable .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( SettingsAction::SelectAndRefresh(subpage_section), ); }) .finish(), ); } } } } } } // Footer: "Open settings file" button, or an inline error alert if // the workspace-level banner was dismissed. Rendered below the // scrollable nav list but inside the same sidebar column so it // shares the right-border and SIDEBAR_WIDTH constraint. let footer_kind = SettingsFooterKind::choose( FeatureFlag::SettingsFile.is_enabled(), self.settings_file_error.is_some(), self.settings_error_banner_dismissed, ); let footer = render_footer( footer_kind, appearance, self.settings_file_error.as_ref(), AISettings::as_ref(app).is_any_ai_enabled(app), &self.footer_mouse_states, ); let scrollable = Container::new( ClippedScrollable::vertical( self.clipped_scroll_state.clone(), buttons.finish(), ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), Fill::None, ) .finish(), ) .with_padding_top(HEADER_PADDING) .finish(); let sidebar = ConstrainedBox::new( Container::new( Flex::column() .with_child(Expanded::new(1., scrollable).finish()) .with_child(footer) .finish(), ) .with_border(Border::right(SECTION_BORDER_WIDTH).with_border_fill(theme.outline())) .finish(), ) .with_width(sidebar_width()) .finish(); let row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_main_axis_size(MainAxisSize::Max) .with_child(Shrinkable::new(1., sidebar).finish()) .with_child(Shrinkable::new(1., page).finish()) .finish(); let mut stack = Stack::new().with_child( EventHandler::new( EventHandler::new(row) .with_always_handle() .on_left_mouse_down(|ctx, _app, _pos| { ctx.dispatch_typed_action(SettingsAction::FocusSelf); DispatchEventResult::PropagateToParent }) .finish(), ) .on_right_mouse_down(|event, _app, position| { let Some(parent_bounds) = event.element_position_by_id(POSITION_ID) else { return DispatchEventResult::PropagateToParent; }; let offset = position - parent_bounds.origin(); event.dispatch_typed_action(SettingsAction::OpenContextMenu(offset)); DispatchEventResult::StopPropagation }) .finish(), ); if let Some(position) = &self.context_menu_state { stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_parent( *position, ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ); } if let Some(modal_content) = current_page_handle.and_then(|handle| self.get_modal_content_for_page(handle, app)) { stack.add_positioned_overlay_child( modal_content, OffsetPositioning::offset_from_parent( pathfinder_geometry::vector::vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::Center, ChildAnchor::Center, ), ); } // Render environment setup mode selector overlay when open. if let Some(selector_handle) = self .environments_page_handle .as_ref(app) .environment_setup_mode_selector_handle() { stack.add_child(ChildView::new(selector_handle).finish()); } // Render agent-assisted environment modal overlay when open. if let Some(modal_handle) = self .environments_page_handle .as_ref(app) .agent_assisted_environment_modal_handle(app) { stack.add_child(ChildView::new(modal_handle).finish()); } SavePosition::new(stack.finish(), POSITION_ID).finish() } } impl TypedActionView for SettingsView { type Action = SettingsAction; fn handle_action(&mut self, action: &SettingsAction, ctx: &mut ViewContext) { match action { SettingsAction::SelectAndRefresh(section) => { self.set_and_refresh_current_page_internal(*section, false, true, ctx); if *section == SettingsSection::MCPServers { send_telemetry_from_ctx!( TelemetryEvent::MCPServerCollectionPaneOpened { entrypoint: MCPServerCollectionPaneEntrypoint::MCPSettingsTab, }, ctx ); } } SettingsAction::ToggleUmbrella(nav_index) => { if let Some(SettingsNavItem::Umbrella(umbrella)) = self.nav_items.get_mut(*nav_index) { umbrella.toggle(); ctx.notify(); } } SettingsAction::MainPageToggle(main_page_action) => { if let Some(main_page) = self.settings_page(SettingsSection::Account) { if let SettingsPageViewHandle::Main(view) = &main_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(main_page_action, ctx); }) } } } SettingsAction::AppearancePageToggle(appearance_action) => { if let Some(appearance_page) = self.settings_page(SettingsSection::Appearance) { if let SettingsPageViewHandle::Appearance(view) = &appearance_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(appearance_action, ctx); }) } } } SettingsAction::FeaturesPageToggle(feature_action) => { if let Some(features_page) = self.settings_page(SettingsSection::Features) { if let SettingsPageViewHandle::Features(view) = &features_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(feature_action, ctx); }) } } } SettingsAction::PrivacyPageToggle(privacy_action) => { if let Some(privacy_page) = self.settings_page(SettingsSection::Privacy) { if let SettingsPageViewHandle::Privacy(view) = &privacy_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(privacy_action, ctx); }) } } } SettingsAction::AI(ai_action) => { if let Some(ai_page) = self.settings_page(SettingsSection::AI) { if let SettingsPageViewHandle::AI(view) = &ai_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(ai_action, ctx); }) } } } SettingsAction::Code(code_action) => { if let Some(code_page) = self.settings_page(SettingsSection::Code) { if let SettingsPageViewHandle::Code(view) = &code_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(code_action, ctx); }) } } } SettingsAction::WarpDrive(warp_drive_action) => { if let Some(warp_drive_page) = self.settings_page(SettingsSection::WarpDrive) { if let SettingsPageViewHandle::WarpDrive(view) = &warp_drive_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(warp_drive_action, ctx); }) } } } SettingsAction::WarpifyPageToggle(warpify_action) => { if let Some(warpify_page) = self.settings_page(SettingsSection::Warpify) { if let SettingsPageViewHandle::Warpify(view) = &warpify_page.view_handle { view.update(ctx, |view, ctx| { view.handle_action(warpify_action, ctx); }) } } } SettingsAction::Tab => self.input_tab(ctx), SettingsAction::Split(direction) => { let event = match direction { Direction::Left => PaneEvent::SplitLeft(None), Direction::Right => PaneEvent::SplitRight(None), Direction::Up => PaneEvent::SplitUp(None), Direction::Down => PaneEvent::SplitDown(None), }; ctx.emit(SettingsViewEvent::Pane(event)); } SettingsAction::ToggleMaximizePane => { ctx.emit(SettingsViewEvent::Pane(PaneEvent::ToggleMaximized)) } SettingsAction::Close => ctx.emit(SettingsViewEvent::Pane(PaneEvent::Close)), SettingsAction::OpenContextMenu(position) => { self.context_menu_state = Some(*position); let menu_items = self.context_menu_items(ctx); self.context_menu.update(ctx, move |menu, ctx| { menu.set_items(menu_items, ctx); ctx.notify(); }); ctx.notify(); } SettingsAction::FocusSelf => ctx.emit(SettingsViewEvent::Pane(PaneEvent::FocusSelf)), SettingsAction::Up => self.key_up(ctx), SettingsAction::Down => self.key_down(ctx), SettingsAction::Debug(action) => self.debug_settings_action(action, ctx), } } } impl BackingView for SettingsView { type PaneHeaderOverflowMenuAction = SettingsAction; type CustomAction = (); type AssociatedData = (); fn handle_pane_header_overflow_menu_action( &mut self, action: &Self::PaneHeaderOverflowMenuAction, ctx: &mut warpui::ViewContext, ) { self.handle_action(action, ctx) } fn close(&mut self, ctx: &mut warpui::ViewContext) { ctx.emit(SettingsViewEvent::Pane(PaneEvent::Close)); } fn focus_contents(&mut self, ctx: &mut warpui::ViewContext) { ctx.focus(&self.search_editor) } fn render_header_content( &self, _ctx: &view::HeaderRenderContext<'_>, _app: &AppContext, ) -> view::HeaderContent { view::HeaderContent::simple("Settings") } fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext) { self.focus_handle = Some(focus_handle); } } #[cfg(test)] #[path = "mod_test.rs"] mod tests;