2576 lines
108 KiB
Rust
2576 lines
108 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
|
|
use about_page::AboutPageView;
|
|
use ai_page::{AISettingsPageAction, AISettingsPageEvent, AISettingsPageView, AISubpage};
|
|
use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
|
|
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent, CodeSubpage};
|
|
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
|
|
use galaxy_core::channel::ChannelState;
|
|
use galaxy_core::context_flag::ContextFlag;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_core::send_telemetry_from_ctx;
|
|
use galaxy_core::settings::ToggleableSetting as _;
|
|
use galaxy_core::ui::theme::color::internal_colors;
|
|
use galaxy_editor::editor::NavigationKey;
|
|
use galaxyui::elements::{
|
|
Align, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable,
|
|
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty,
|
|
EventHandler, Expanded, Fill, Flex, MainAxisSize, OffsetPositioning, ParentAnchor,
|
|
ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollbarWidth, Shrinkable, Stack,
|
|
Text,
|
|
};
|
|
use galaxyui::fonts::{Properties, Weight};
|
|
use galaxyui::keymap::{ContextPredicate, EnabledPredicate, FixedBinding};
|
|
use galaxyui::{
|
|
id, Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView,
|
|
UpdateView as _, View, ViewContext, ViewHandle,
|
|
};
|
|
use itertools::Itertools as _;
|
|
use keybindings::KeybindingsView;
|
|
use mcp_servers_page::MCPServersSettingsPageView;
|
|
use nav::{SettingsNavItem, SettingsUmbrella};
|
|
use pathfinder_geometry::vector::Vector2F;
|
|
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
|
|
use scripting_page::ScriptingSettingsPageView;
|
|
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
|
|
use settings_page::{
|
|
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
|
|
HEADER_PADDING,
|
|
};
|
|
use teams_page::{TeamsPageView, TeamsPageViewEvent};
|
|
use warpify_page::{WarpifyPageAction, WarpifyPageView};
|
|
|
|
use self::telemetry::SettingsTelemetryEvent;
|
|
use crate::ai::custom_model_routers::CustomModelRouter;
|
|
use crate::ai::execution_profiles::profiles::ClientProfileId;
|
|
use crate::appearance::Appearance;
|
|
use crate::editor::{
|
|
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
|
TextColors, TextOptions,
|
|
};
|
|
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
|
|
use crate::pane_group::focus_state::PaneFocusHandle;
|
|
use crate::pane_group::pane::view;
|
|
use crate::pane_group::{BackingView, Direction, PaneConfiguration, PaneEvent, SplitPaneState};
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::server::telemetry::MCPServerCollectionPaneEntrypoint;
|
|
use crate::settings::{AISettings, BlockVisibilitySettings, SettingsFileError};
|
|
use crate::settings_view::mcp_servers_page::{MCPServersSettingsPage, MCPServersSettingsPageEvent};
|
|
use crate::terminal::model::blockgrid::BlockGrid;
|
|
use crate::terminal::SizeInfo;
|
|
use crate::ui_components::icons;
|
|
use crate::util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction};
|
|
use crate::view_components::ToastFlavor;
|
|
use crate::workspace::WorkspaceAction;
|
|
use crate::{GlobalResourceHandlesProvider, TelemetryEvent};
|
|
|
|
mod about_page;
|
|
mod admin_actions;
|
|
mod agent_assisted_environment_modal;
|
|
mod ai_page;
|
|
mod appearance_page;
|
|
mod code_page;
|
|
mod custom_router_view;
|
|
mod directory_color_add_picker;
|
|
mod execution_profile_view;
|
|
mod features;
|
|
mod features_page;
|
|
pub mod keybindings;
|
|
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 provider_setup_modal;
|
|
mod scripting_page;
|
|
mod set_default_model_modal;
|
|
mod settings_file_footer;
|
|
pub(crate) mod settings_page;
|
|
mod tab_menu;
|
|
mod teams_page;
|
|
mod telemetry;
|
|
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(crate) use ai_page::custom_model_routers_widget_id;
|
|
pub use code_page::CodeSettingsPageView;
|
|
pub use features_page::FeaturesPageAction;
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// Small inline pill rendered next to a settings label to mark a feature as beta.
|
|
/// Used for experimental features (i.e. AsyncFind) that are enabled for Friends of Warp (i.e. Dogfood/Preview) and toggleable by others.
|
|
pub(super) fn render_beta_chip(appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let chip_color = theme.sub_text_color(theme.surface_3()).into_solid();
|
|
Container::new(
|
|
Text::new_inline("BETA", appearance.ui_font_family(), 10.)
|
|
.with_color(chip_color)
|
|
.finish(),
|
|
)
|
|
.with_background(theme.surface_3())
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
|
|
.with_horizontal_padding(4.)
|
|
.with_vertical_padding(1.)
|
|
.with_margin_left(8.)
|
|
.finish()
|
|
}
|
|
|
|
/// Renders a horizontal row of pill-shaped chips for model labels.
|
|
/// Used by custom inference endpoint cards and the remove confirmation dialog.
|
|
pub(super) fn render_model_chips(
|
|
labels: impl IntoIterator<Item = String>,
|
|
appearance: &Appearance,
|
|
text_color: galaxy_core::ui::theme::Fill,
|
|
) -> Box<dyn Element> {
|
|
use warpui::ui_components::chip::Chip;
|
|
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
|
|
|
let theme = appearance.theme();
|
|
let chip_border = internal_colors::neutral_4(theme).into();
|
|
let chip_style = UiComponentStyles {
|
|
background: None,
|
|
border_color: Some(chip_border),
|
|
border_width: Some(1.),
|
|
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_size: Some(appearance.ui_font_size()),
|
|
font_color: Some(text_color.into_solid()),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut chips = Flex::row().with_spacing(8.);
|
|
for label in labels {
|
|
chips.add_child(Chip::new(label, chip_style).build().finish());
|
|
}
|
|
chips.finish()
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub enum SettingsViewEvent {
|
|
Pane(PaneEvent),
|
|
StartResize,
|
|
CheckForUpdate,
|
|
LaunchNetworkLogging,
|
|
OpenGalaxyDrive,
|
|
SignupAnonymousUser,
|
|
ShowToast {
|
|
message: String,
|
|
flavor: ToastFlavor,
|
|
},
|
|
OpenAIFactCollection,
|
|
OpenMCPServerCollection,
|
|
OpenCustomRouterEditor(Option<CustomModelRouter>),
|
|
OpenCustomRouterFile(PathBuf),
|
|
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,
|
|
MCPServers,
|
|
#[default]
|
|
Appearance,
|
|
Features,
|
|
Keybindings,
|
|
Privacy,
|
|
Scripting,
|
|
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,
|
|
Models,
|
|
Experiments,
|
|
/// 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,
|
|
}
|
|
|
|
use std::fmt::{self, Display};
|
|
|
|
use crate::util::bindings::custom_tag_to_keystroke;
|
|
|
|
impl Display for SettingsSection {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
|
|
SettingsSection::MCPServers => write!(f, "MCP Servers"),
|
|
SettingsSection::Scripting => write!(f, "Galaxy Control"),
|
|
SettingsSection::WarpDrive => write!(f, "Galaxy Drive"),
|
|
SettingsSection::WarpAgent => write!(f, "Galaxy 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::Models => write!(f, "Models"),
|
|
SettingsSection::Experiments => write!(f, "Experiments"),
|
|
SettingsSection::Warpify => write!(f, "Wormhole"),
|
|
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
|
|
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
|
|
_ => 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()
|
|
}
|
|
|
|
/// 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
|
|
| Self::Models
|
|
| Self::Experiments
|
|
)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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,
|
|
other => *other,
|
|
}
|
|
}
|
|
|
|
/// The ordered list of AI subpage sections shown under the Agents umbrella.
|
|
pub fn ai_subpages() -> &'static [Self] {
|
|
&[
|
|
Self::WarpAgent,
|
|
Self::Models,
|
|
Self::AgentProfiles,
|
|
Self::AgentMCPServers,
|
|
Self::Knowledge,
|
|
Self::ThirdPartyCLIAgents,
|
|
Self::Experiments,
|
|
]
|
|
}
|
|
|
|
/// The ordered list of Code subpage sections shown under the Code umbrella.
|
|
pub fn code_subpages() -> &'static [Self] {
|
|
&[Self::CodeIndexing, Self::EditorAndCodeReview]
|
|
}
|
|
}
|
|
|
|
impl FromStr for SettingsSection {
|
|
type Err = ();
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"About" => Ok(Self::About),
|
|
"AI" => Ok(Self::AI),
|
|
"MCP Servers" => Ok(Self::MCPServers),
|
|
"Appearance" => Ok(Self::Appearance),
|
|
"Code" => Ok(Self::Code),
|
|
"Features" => Ok(Self::Features),
|
|
"Keyboard shortcuts" => Ok(Self::Keybindings),
|
|
"Privacy" => Ok(Self::Privacy),
|
|
"Galaxy Control" | "Scripting" => Ok(Self::Scripting),
|
|
"Teams" => Ok(Self::Teams),
|
|
"Warpify" => Ok(Self::Warpify),
|
|
"WarpDrive" | "Galaxy Drive" => Ok(Self::WarpDrive),
|
|
"Oz" | "Warp Agent" | "Galaxy 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),
|
|
"Models"
|
|
| "AWS Bedrock"
|
|
| "Bedrock"
|
|
| "OpenAI / LiteLLM"
|
|
| "OpenAI"
|
|
| "Agent runtimes"
|
|
| "Agent Client Protocol"
|
|
| "ACP" => Ok(Self::Models),
|
|
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
|
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
|
"Experiments" => Ok(Self::Experiments),
|
|
"Wormhole" => Ok(Self::Warpify),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves a stable, friendly deeplink slug (used by
|
|
/// `warp://settings?widget=<slug>`) to the settings page and `&'static str`
|
|
/// widget id it should scroll to.
|
|
///
|
|
/// Only allowlisted widgets are linkable, so the public URL contract stays
|
|
/// stable and internal widget identifiers (Rust type names) are not exposed.
|
|
/// Add an entry here to make a new widget deep-linkable.
|
|
pub fn settings_widget_deeplink_target(slug: &str) -> Option<(SettingsSection, &'static str)> {
|
|
match slug {
|
|
"global_hotkey" => Some((
|
|
SettingsSection::Features,
|
|
features_page::global_hotkey_widget_id(),
|
|
)),
|
|
"custom_router" => Some((SettingsSection::WarpAgent, custom_model_routers_widget_id())),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
"cli_agents" => Some((
|
|
SettingsSection::ThirdPartyCLIAgents,
|
|
cli_agent_settings_widget_id(),
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub struct DisplayCount(pub usize);
|
|
|
|
impl Entity for DisplayCount {
|
|
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 MOUSE_REPORTING_CONTEXT_FLAG: &str = "Mouse_Reporting";
|
|
pub const SCROLL_REPORTING_CONTEXT_FLAG: &str = "Scroll_Reporting";
|
|
pub const FOCUS_REPORTING_CONTEXT_FLAG: &str = "Focus_Reporting";
|
|
pub const SSH_REUSE_CONTROL_MASTER_CONTEXT_FLAG: &str = "SSH_Reuse_Control_Master";
|
|
pub const SSH_WARPIFICATION_CONTEXT_FLAG: &str = "SSH_Warpification";
|
|
pub const NOTIFICATIONS_CONTEXT_FLAG: &str = "Notifications_Enabled";
|
|
pub const LONG_RUNNING_NOTIFICATIONS_FLAG: &str = "Long_Running_Notifications";
|
|
pub const AGENT_TASK_COMPLETED_NOTIFICATIONS_FLAG: &str = "Agent_Task_Completed_Notifications";
|
|
pub const NEEDS_ATTENTION_NOTIFICATIONS_FLAG: &str = "Needs_Attention_Notifications";
|
|
pub const NOTIFICATION_SOUND_FLAG: &str = "Notification_Sound";
|
|
pub const AGENT_IN_APP_NOTIFICATIONS_FLAG: &str = "Agent_In_App_Notifications";
|
|
pub const LINK_TOOLTIP_CONTEXT_FLAG: &str = "Link_Tooltip";
|
|
pub const COMPACT_MODE_CONTEXT_FLAG: &str = "Compact_Mode_Enabled";
|
|
pub const CURSOR_BLINK_CONTEXT_FLAG: &str = "Cursor_Blink_Enabled";
|
|
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 CLOUD_CONVERSATION_STORAGE_FLAG: &str = "Cloud_Conversation_Storage_Enabled";
|
|
pub const CLOUD_CONVERSATION_STORAGE_EDITABLE_FLAG: &str =
|
|
"Cloud_Conversation_Storage_Editable";
|
|
pub const DIM_INACTIVE_PANES_FLAG: &str = "Dim_Inactive_Panes";
|
|
pub const OPEN_WINDOWS_AT_CUSTOM_SIZE_FLAG: &str = "Open_Windows_At_Custom_Size";
|
|
pub const WINDOW_BLUR_TEXTURE_FLAG: &str = "Window_Blur_Texture";
|
|
pub const LEFT_PANEL_VISIBILITY_ACROSS_TABS_FLAG: &str = "Left_Panel_Visibility_Across_Tabs";
|
|
pub const MATCH_AI_FONT_TO_TERMINAL_FONT_FLAG: &str = "Match_AI_Font_To_Terminal_Font";
|
|
pub const MATCH_NOTEBOOK_FONT_SIZE_TO_TERMINAL_FONT_SIZE_FLAG: &str =
|
|
"Match_Notebook_Font_Size_To_Terminal_Font_Size";
|
|
pub const QUIT_WARNING_MODAL: &str = "Quit_Warning_Modal";
|
|
pub const BLOCK_DIVIDERS_CONTEXT_FLAG: &str = "Block_Dividers_Enabled";
|
|
|
|
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 SHOW_CODE_REVIEW_DIFF_STATS_FLAG: &str = "Show_Code_Review_Diff_Stats_Enabled";
|
|
pub const AUTO_OPEN_CODE_REVIEW_PANE_FLAG: &str = "Auto_Open_Code_Review_Pane_Enabled";
|
|
pub const USE_VERTICAL_TABS_FLAG: &str = "Use_Vertical_Tabs";
|
|
pub const PRESERVE_ACTIVE_TAB_COLOR_FLAG: &str = "Preserve_Active_Tab_Color";
|
|
pub const SHOW_VERTICAL_TAB_PANEL_IN_RESTORED_WINDOWS_FLAG: &str =
|
|
"Show_Vertical_Tab_Panel_In_Restored_Windows";
|
|
pub const USE_LATEST_USER_PROMPT_AS_CONVERSATION_TITLE_IN_TAB_NAMES_FLAG: &str =
|
|
"Use_Latest_User_Prompt_As_Conversation_Title_In_Tab_Names";
|
|
pub const ALT_SCREEN_PADDING_FLAG: &str = "Alt_Screen_Padding";
|
|
pub const SESSION_CONFIG_TAB_CONFIG_CHIP_OPEN: &str = "Session_Config_Tab_Config_Chip_Open";
|
|
pub const FOCUS_PANES_ON_HOVER_CONTEXT_FLAG: &str = "Focus_Panes_On_Hover";
|
|
pub const HIDE_WORKSPACE_DECORATIONS_CONTEXT_FLAG: &str = "Hide_Workspace_Decorations";
|
|
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 ORCHESTRATION_MESSAGE_DISPLAY_SHOW_AND_COLLAPSE: &str =
|
|
"Orchestration_Message_Display_ShowAndCollapse";
|
|
pub const ORCHESTRATION_MESSAGE_DISPLAY_ALWAYS_SHOW: &str =
|
|
"Orchestration_Message_Display_AlwaysShow";
|
|
pub const ORCHESTRATION_MESSAGE_DISPLAY_ALWAYS_COLLAPSE: &str =
|
|
"Orchestration_Message_Display_AlwaysCollapse";
|
|
pub const PROMPT_SUBMISSION_INTERRUPT: &str = "Prompt_Submission_Interrupt";
|
|
pub const PROMPT_SUBMISSION_QUEUE: &str = "Prompt_Submission_Queue";
|
|
pub const LRC_SUBMISSION_SEND_IMMEDIATELY: &str = "LRC_Submission_Send_Immediately";
|
|
pub const LRC_SUBMISSION_QUEUE_UNTIL_COMMAND_COMPLETES: &str =
|
|
"LRC_Submission_Queue_Until_Command_Completes";
|
|
pub const SHOW_TERMINAL_INPUT_MESSAGE_LINE_FLAG: &str = "Show_Terminal_Input_Message_Line";
|
|
pub const PRESERVE_INPUT_FOCUS_ON_BLOCK_SELECTION_FLAG: &str =
|
|
"Preserve_Input_Focus_On_Block_Selection";
|
|
pub const SLASH_COMMANDS_IN_TERMINAL_FLAG: &str = "Slash_Commands_In_Terminal";
|
|
pub const AT_CONTEXT_MENU_IN_TERMINAL_FLAG: &str = "At_Context_Menu_In_Terminal";
|
|
pub const OUTLINE_CODEBASE_SYMBOLS_FOR_AT_CONTEXT_MENU_FLAG: &str =
|
|
"Outline_Codebase_Symbols_For_At_Context_Menu";
|
|
pub const AUTOSUGGESTIONS_ENABLED_FLAG: &str = "Autosuggestions_Enabled";
|
|
pub const AUTOSUGGESTION_KEYBINDING_HINT_FLAG: &str = "Hide_Autosuggestion_Keybinding_Hint";
|
|
pub const SHOW_AUTOSUGGESTION_IGNORE_BUTTON_FLAG: &str = "Show_Autosuggestion_Ignore_Button";
|
|
pub const SHOW_TERMINAL_ZERO_STATE_BLOCK_FLAG: &str = "Show_Terminal_Zero_State_Block";
|
|
pub const GLOBAL_WORKFLOWS_IN_COMMAND_SEARCH_FLAG: &str = "Global_Workflows_In_Command_Search";
|
|
pub const PREFER_LOW_POWER_GPU_FLAG: &str = "Prefer_Low_Power_GPU";
|
|
pub const INITIALIZATION_BLOCK_FLAG: &str = "Initialization_Block_Visible";
|
|
pub const IN_BAND_COMMAND_BLOCKS_FLAG: &str = "In_Band_Command_Blocks_Visible";
|
|
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 GIT_OPERATIONS_AUTOGEN_FLAG: &str = "Git_Operations_Autogen";
|
|
pub const INCLUDE_AGENT_COMMANDS_IN_HISTORY_FLAG: &str = "Include_Agent_Commands_In_History";
|
|
pub const AI_RULES_FLAG: &str = "AI_Rules";
|
|
pub const SUGGESTED_RULES_FLAG: &str = "Suggested_Rules";
|
|
pub const WARP_DRIVE_CONTEXT_FLAG: &str = "Warp_Drive_Context";
|
|
pub const FILE_BASED_MCP_FLAG: &str = "File_Based_MCP";
|
|
pub const ACP_ENABLED_FLAG: &str = "Agent_Client_Protocol_Enabled";
|
|
pub const SHOW_BASE_MODEL_PICKER_IN_PROMPT_FLAG: &str = "Show_Base_Model_Picker_In_Prompt";
|
|
pub const DEBUG_SHOW_MEMORY_STATS_FLAG: &str = "Debug_Memory_Statistics";
|
|
pub const ALLOW_NATIVE_WAYLAND: &str = "Allow_Native_Wayland";
|
|
pub const IS_ANY_AI_ENABLED: &str = "IsAnyAIEnabled";
|
|
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";
|
|
/// When set, the terminal input owns Page Up / Page Down so the editor's fixed bindings
|
|
/// should not match.
|
|
pub const TERMINAL_INPUT_PAGE_KEYS_HANDLED_BY_INPUT: &str =
|
|
"TerminalInputPageKeysHandledByInput";
|
|
pub const HAS_PENDING_PROMPT_SUGGESTION: &str = "HasPendingPromptSuggestion";
|
|
pub const ACTIVE_AGENT_VIEW: &str = "ActiveAgentView";
|
|
pub const ACTIVE_INLINE_AGENT_VIEW: &str = "ActiveInlineAgentView";
|
|
/// 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 AUTO_TOGGLE_RICH_INPUT_FLAG: &str = "AutoToggleRichInput";
|
|
pub const AUTO_OPEN_RICH_INPUT_ON_CLI_AGENT_START_FLAG: &str =
|
|
"AutoOpenRichInputOnCLIAgentStart";
|
|
pub const AUTO_DISMISS_RICH_INPUT_AFTER_SUBMIT_FLAG: &str = "AutoDismissRichInputAfterSubmit";
|
|
pub const ENABLE_WARP_DRIVE: &str = "EnableWarpDrive";
|
|
pub const GALAXY_CONTROL_ENABLED: &str = "GalaxyControlEnabled";
|
|
// Tools panel settings
|
|
pub const SHOW_CONVERSATION_HISTORY: &str = "ShowConversationHistory";
|
|
pub const SHOW_PROJECT_EXPLORER: &str = "ShowProjectExplorer";
|
|
pub const SHOW_GLOBAL_SEARCH: &str = "ShowGlobalSearch";
|
|
pub const SHOW_HIDDEN_FILES: &str = "ShowHiddenFiles";
|
|
}
|
|
|
|
pub fn init_actions_from_parent_view<T: Action + Clone>(
|
|
app: &mut AppContext,
|
|
context: &ContextPredicate,
|
|
builder: fn(SettingsAction) -> T,
|
|
) {
|
|
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);
|
|
warp_drive_page::init_actions_from_parent_view(app, context, builder);
|
|
|
|
if ChannelState::enable_debug_features() || cfg!(windows) {
|
|
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
|
|
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 galaxyui::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(mut self, value: bool) -> Self {
|
|
self.supported_on_current_platform = value;
|
|
self
|
|
}
|
|
|
|
/// Creates enable/disable bindings for a toggle feature, given a list of `ToggleSettingActionPair`'s.
|
|
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),
|
|
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::Appearance(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Scripting(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::MCPServers(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::WarpDrive(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Teams(handle) => $ctx.update_view(handle, $update),
|
|
SettingsPageViewHandle::Platform(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>,
|
|
/// 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 `GALAXY.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();
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
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 scripting_page_handle = if FeatureFlag::GalaxyControlCli.is_enabled() {
|
|
Some(ctx.add_typed_action_view(ScriptingSettingsPageView::new))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Warp Drive page
|
|
let warp_drive_page_handle =
|
|
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
|
|
|
|
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(ai_page_handle),
|
|
SettingsPage::new(code_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(warp_drive_page_handle),
|
|
];
|
|
|
|
if let Some(scripting_page_handle) = scripting_page_handle {
|
|
settings_pages.push(SettingsPage::new(scripting_page_handle));
|
|
}
|
|
|
|
settings_pages.extend(vec![
|
|
SettingsPage::new(mcp_servers_page_handle),
|
|
SettingsPage::new(privacy_page_handle),
|
|
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::Umbrella(SettingsUmbrella::new(
|
|
"Agents",
|
|
SettingsSection::ai_subpages().to_vec(),
|
|
)),
|
|
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
|
"Code",
|
|
vec![
|
|
SettingsSection::CodeIndexing,
|
|
SettingsSection::EditorAndCodeReview,
|
|
],
|
|
)),
|
|
SettingsNavItem::Page(SettingsSection::Appearance),
|
|
SettingsNavItem::Page(SettingsSection::Features),
|
|
SettingsNavItem::Page(SettingsSection::Keybindings),
|
|
SettingsNavItem::Page(SettingsSection::Warpify),
|
|
SettingsNavItem::Page(SettingsSection::WarpDrive),
|
|
SettingsNavItem::Page(SettingsSection::Privacy),
|
|
SettingsNavItem::Page(SettingsSection::About),
|
|
];
|
|
|
|
if FeatureFlag::GalaxyControlCli.is_enabled() {
|
|
nav_items.push(SettingsNavItem::Page(SettingsSection::Scripting));
|
|
}
|
|
|
|
// Resolve the initial page: map internal backing-page sections to their default subpage.
|
|
let initial_page = match page {
|
|
Some(SettingsSection::AI) => SettingsSection::WarpAgent,
|
|
Some(SettingsSection::Code) => SettingsSection::CodeIndexing,
|
|
Some(SettingsSection::Scripting) if !FeatureFlag::GalaxyControlCli.is_enabled() => {
|
|
SettingsSection::About
|
|
}
|
|
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(),
|
|
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_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_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_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)
|
|
}
|
|
#[cfg(feature = "local_fs")]
|
|
AISettingsPageEvent::OpenCustomRouterEditor(router) => {
|
|
ctx.emit(SettingsViewEvent::OpenCustomRouterEditor(router.clone()));
|
|
}
|
|
#[cfg(feature = "local_fs")]
|
|
AISettingsPageEvent::OpenCustomRouterFile(path) => {
|
|
ctx.emit(SettingsViewEvent::OpenCustomRouterFile(path.clone()));
|
|
}
|
|
AISettingsPageEvent::OpenExecutionProfileEditor(profile_id) => {
|
|
ctx.emit(SettingsViewEvent::OpenExecutionProfileEditor(*profile_id));
|
|
}
|
|
AISettingsPageEvent::SignupAnonymousUser => {
|
|
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
|
}
|
|
AISettingsPageEvent::ShowModal | AISettingsPageEvent::HideModal => {
|
|
// Modal rendering is handled in get_modal_content_for_page
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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::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::About(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::Scripting(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::WarpDrive(v) => v.as_ref(app).should_render(app),
|
|
SettingsPageViewHandle::Platform(v) => v.as_ref(app).should_render(app),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
if let SettingsPageViewHandle::Keybindings(view_handle) = ¤t_page.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::Privacy(view) => {
|
|
view.read(app, |view, _| view.get_modal_content())
|
|
}
|
|
SettingsPageViewHandle::MCPServers(view) => {
|
|
view.read(app, |view, _| view.get_modal_content(app))
|
|
}
|
|
SettingsPageViewHandle::AI(view) => {
|
|
view.read(app, |view, _| view.get_modal_content(app))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
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_galaxyui_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,
|
|
),
|
|
);
|
|
}
|
|
|
|
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::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 galaxyui::ViewContext<Self>,
|
|
) {
|
|
self.handle_action(action, ctx)
|
|
}
|
|
|
|
fn close(&mut self, ctx: &mut galaxyui::ViewContext<Self>) {
|
|
ctx.emit(SettingsViewEvent::Pane(PaneEvent::Close));
|
|
}
|
|
|
|
fn focus_contents(&mut self, ctx: &mut galaxyui::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_tests.rs"]
|
|
mod tests;
|