2668 lines
111 KiB
Rust
2668 lines
111 KiB
Rust
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<PathBuf>,
|
|
},
|
|
}
|
|
|
|
/// 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<Self, Self::Err> {
|
|
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<T: Action + Clone>(
|
|
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<T: Action + Clone> {
|
|
/// 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<CustomAction>,
|
|
/// 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<EnabledPredicate>,
|
|
|
|
/// Whether or not this pairing applies to the current platform (Mac, Linux, Web, etc.)
|
|
supported_on_current_platform: bool,
|
|
}
|
|
|
|
impl<T: Action + Clone> ToggleSettingActionPair<T> {
|
|
/// `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<CustomAction>,
|
|
) -> 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<ToggleSettingActionPair<T>>,
|
|
app: &mut AppContext,
|
|
) {
|
|
let (enable_bindings, disable_bindings): (Vec<FixedBinding>, Vec<FixedBinding>) =
|
|
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<F>(nav_items: &[SettingsNavItem], is_visible: F) -> Vec<NavStop>
|
|
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<SettingsSection> = 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<usize> {
|
|
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<PaneConfiguration>,
|
|
focus_handle: Option<PaneFocusHandle>,
|
|
settings_pages: Vec<SettingsPage>,
|
|
pages_filter: Vec<MatchData>,
|
|
current_settings_page: SettingsSection,
|
|
search_editor: ViewHandle<EditorView>,
|
|
clipped_scroll_state: ClippedScrollStateHandle,
|
|
context_menu: ViewHandle<Menu<SettingsAction>>,
|
|
context_menu_state: Option<Vector2F>,
|
|
environments_page_handle: ViewHandle<EnvironmentsPageView>,
|
|
/// Sidebar navigation items (pages + umbrellas).
|
|
nav_items: Vec<SettingsNavItem>,
|
|
/// Handle to the AI settings page, used to switch subpage modes.
|
|
ai_page_handle: ViewHandle<AISettingsPageView>,
|
|
/// Handle to the Code settings page, used to switch subpage modes.
|
|
code_page_handle: ViewHandle<CodeSettingsPageView>,
|
|
/// Per-subpage search match results. Populated during search so that
|
|
/// subpages sharing the same backing page can be filtered independently.
|
|
subpage_filter: HashMap<SettingsSection, MatchData>,
|
|
/// 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<SettingsFileError>,
|
|
/// 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<SettingsSection>, ctx: &mut ViewContext<Self>) -> 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<SettingsFileError>,
|
|
banner_dismissed: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<PaneConfiguration> {
|
|
self.pane_configuration.clone()
|
|
}
|
|
|
|
pub fn focus(&mut self, ctx: &mut ViewContext<Self>) {
|
|
ctx.focus(&self.search_editor);
|
|
ctx.emit(SettingsViewEvent::Pane(PaneEvent::FocusSelf));
|
|
}
|
|
|
|
fn filtered_pages<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
) -> impl Iterator<Item = (&'a SettingsPage, MatchData)> {
|
|
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<EditorView>,
|
|
event: &EditorEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<Self>) -> Vec<MenuItem<SettingsAction>> {
|
|
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<Self>) {
|
|
if let menu::Event::Close { .. } = event {
|
|
self.context_menu_state.take();
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
fn clear_search_query(&mut self, ctx: &mut ViewContext<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
match event {
|
|
warp_drive_page::WarpDriveSettingsPageEvent::SignUp => {
|
|
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
// 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>,
|
|
) {
|
|
self.set_and_refresh_current_page_internal(section, true, true, ctx);
|
|
}
|
|
|
|
pub fn set_search_query(&mut self, query: &str, ctx: &mut ViewContext<Self>) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
// 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<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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>) {
|
|
self.cycle_pages(CycleDirection::Up, ctx)
|
|
}
|
|
|
|
fn key_down(&mut self, ctx: &mut ViewContext<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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>,
|
|
) {
|
|
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<Self>) {
|
|
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<Box<dyn Element>> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<Self>) {
|
|
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>,
|
|
) {
|
|
self.handle_action(action, ctx)
|
|
}
|
|
|
|
fn close(&mut self, ctx: &mut warpui::ViewContext<Self>) {
|
|
ctx.emit(SettingsViewEvent::Pane(PaneEvent::Close));
|
|
}
|
|
|
|
fn focus_contents(&mut self, ctx: &mut warpui::ViewContext<Self>) {
|
|
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>) {
|
|
self.focus_handle = Some(focus_handle);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_test.rs"]
|
|
mod tests;
|